diff --git a/easytier-core/Cargo.toml b/easytier-core/Cargo.toml index 0edba337..36919f2c 100644 --- a/easytier-core/Cargo.toml +++ b/easytier-core/Cargo.toml @@ -122,11 +122,13 @@ endpoint-discovery = [ "dep:webpki-roots", ] dhcp-ipv4 = [] +dhcp-ipv6 = [] public-ipv6-provider = [] vpn-portal = [] wrapped-transport = [] extended-services = [ "dhcp-ipv4", + "dhcp-ipv6", "public-ipv6-provider", "vpn-portal", "wrapped-transport", diff --git a/easytier-core/src/config/api.rs b/easytier-core/src/config/api.rs index 9b7056b9..1f38eed9 100644 --- a/easytier-core/src/config/api.rs +++ b/easytier-core/src/config/api.rs @@ -17,6 +17,11 @@ pub fn network_config_from_toml(config: &TomlConfig) -> 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()), + dhcpv6: Some(config.get_dhcpv6()), + dhcp_ipv6_cidr: config + .get_dhcpv6_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 4b2b9661..5f4fbffa 100644 --- a/easytier-core/src/config/api_input.rs +++ b/easytier-core/src/config/api_input.rs @@ -121,6 +121,7 @@ const FORM_MANAGED_TOML_FIELDS: &[&str] = &[ "ipv6_public_addr_auto", "ipv6_public_addr_prefix", "dhcp", + "dhcpv6", "network_identity", "listeners", "mapped_listeners", @@ -256,6 +257,12 @@ impl NetworkConfigExt for NetworkConfig { { cfg.set_dhcp_config(super::DhcpConfig::from_cidr(cidr)?); } + cfg.set_dhcpv6(self.dhcpv6.unwrap_or(self.dhcp_ipv6_cidr.is_some())); + if cfg.get_dhcpv6() + && let Some(cidr) = &self.dhcp_ipv6_cidr + { + cfg.set_dhcpv6_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 @@ -557,6 +564,11 @@ impl NetworkConfigExt for NetworkConfig { result.dhcp = Some(config.get_dhcp()); result.dhcp_ipv4_cidr = config.get_dhcp_config().cidr().map(|cidr| cidr.to_string()); + result.dhcpv6 = Some(config.get_dhcpv6()); + result.dhcp_ipv6_cidr = config + .get_dhcpv6_config() + .cidr() + .map(|cidr| cidr.to_string()); let network_identity = config.get_network_identity(); result.network_name = Some(network_identity.network_name.clone()); @@ -735,6 +747,38 @@ mod tests { } } + #[test] + fn dhcpv6_cidr_survives_management_round_trips() { + let mut input = NetworkConfig { + dhcp: Some(true), + dhcp_ipv6_cidr: Some("fd12::/64".to_owned()), + ..standalone_config() + }; + for enabled in [None, Some(true), Some(false)] { + input.dhcpv6 = enabled; + let config = input.gen_config().unwrap(); + assert_eq!(config.get_dhcpv6(), enabled.unwrap_or(true)); + let readback = NetworkConfig::new_from_config(&config).unwrap(); + assert_eq!(readback.dhcpv6, Some(enabled.unwrap_or(true))); + assert_eq!( + readback.dhcp_ipv6_cidr, + if enabled == Some(false) { + None + } else { + input.dhcp_ipv6_cidr.clone() + } + ); + assert_eq!( + readback.gen_config().unwrap().get_dhcpv6_config(), + config.get_dhcpv6_config() + ); + assert_eq!( + super::super::api::network_config_from_toml(&config).dhcp_ipv6_cidr, + readback.dhcp_ipv6_cidr + ); + } + } + #[test] fn dhcp_api_rejects_invalid_or_unusable_subnets() { let mut input = standalone_config(); diff --git a/easytier-core/src/config/dhcp.rs b/easytier-core/src/config/dhcp.rs index 97e3fc33..3fcc4104 100644 --- a/easytier-core/src/config/dhcp.rs +++ b/easytier-core/src/config/dhcp.rs @@ -1,29 +1,29 @@ -use std::{fmt, str::FromStr}; +use std::{fmt, marker::PhantomData, str::FromStr}; use anyhow::Context; -use cidr::Ipv4Cidr; +use cidr::{Cidr, Family, Ipv4Cidr, Ipv6Cidr}; 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 { +pub enum DhcpConfig { Enabled(bool), - Cidr(Ipv4Cidr), + Cidr(C), } -impl Default for DhcpConfig { +impl Default for DhcpConfig { fn default() -> Self { Self::Enabled(false) } } -impl DhcpConfig { +impl DhcpConfig { pub fn enabled(self) -> bool { !matches!(self, Self::Enabled(false)) } - pub fn cidr(self) -> Option { + pub fn cidr(self) -> Option { match self { Self::Enabled(_) => None, Self::Cidr(cidr) => Some(cidr), @@ -31,18 +31,27 @@ impl DhcpConfig { } pub fn from_cidr(value: &str) -> anyhow::Result { - let cidr: Ipv4Cidr = value + let cidr: C = 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)" - ); + .with_context(|| format!("invalid DHCP CIDR: {value}"))?; + match cidr.family() { + Family::Ipv4 => anyhow::ensure!( + cidr.network_length() <= 30, + "DHCP IPv4 CIDR must have usable host addresses (prefix length 0..=30)" + ), + Family::Ipv6 => { + let subnet: Ipv6Cidr = value.parse()?; + anyhow::ensure!( + valid_ipv6_subnet(subnet), + "DHCP IPv6 CIDR must be a unicast subnet within fc00::/7 or 2000::/3, with prefix length at most 126" + ); + } + } Ok(Self::Cidr(cidr)) } } -impl FromStr for DhcpConfig { +impl FromStr for DhcpConfig { type Err = anyhow::Error; fn from_str(value: &str) -> Result { @@ -53,15 +62,15 @@ impl FromStr for DhcpConfig { } } -impl<'de> Deserialize<'de> for DhcpConfig { +impl<'de, C: Cidr> Deserialize<'de> for DhcpConfig { fn deserialize>(deserializer: D) -> Result { - struct DhcpVisitor; + struct DhcpVisitor(PhantomData); - impl de::Visitor<'_> for DhcpVisitor { - type Value = DhcpConfig; + 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") + formatter.write_str("a boolean or a usable DHCP CIDR") } fn visit_bool(self, value: bool) -> Result { @@ -73,6 +82,15 @@ impl<'de> Deserialize<'de> for DhcpConfig { } } - deserializer.deserialize_any(DhcpVisitor) + deserializer.deserialize_any(DhcpVisitor(PhantomData)) } } + +// Only overlay unicast subnets are eligible for automatic assignment. Keep at +// least two candidates after reserving the subnet endpoints. +pub(crate) fn valid_ipv6_subnet(subnet: Ipv6Cidr) -> bool { + let first = subnet.first_address().segments()[0]; + subnet.network_length() <= 126 + && ((subnet.network_length() >= 7 && first & 0xfe00 == 0xfc00) + || (subnet.network_length() >= 3 && first & 0xe000 == 0x2000)) +} diff --git a/easytier-core/src/config/mod.rs b/easytier-core/src/config/mod.rs index 4b960a7f..07c0d3e2 100644 --- a/easytier-core/src/config/mod.rs +++ b/easytier-core/src/config/mod.rs @@ -18,6 +18,7 @@ pub mod runtime; pub mod toml; pub use dhcp::DhcpConfig; +pub(crate) use dhcp::valid_ipv6_subnet; 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 854cf275..6d716553 100644 --- a/easytier-core/src/config/runtime.rs +++ b/easytier-core/src/config/runtime.rs @@ -3,7 +3,7 @@ use std::{collections::BTreeSet, sync::Arc}; use arc_swap::ArcSwap; -use cidr::Ipv4Cidr; +use cidr::{Ipv4Cidr, Ipv6Cidr}; use parking_lot::Mutex; use serde::{Deserialize, Serialize}; @@ -18,6 +18,10 @@ pub struct CoreRuntimeConfig { pub dhcp_ipv4: bool, #[serde(default, skip_serializing_if = "Option::is_none")] pub dhcp_ipv4_cidr: Option, + #[serde(default)] + pub dhcp_ipv6: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dhcp_ipv6_cidr: Option, pub gateway: GatewayRuntimeConfig, #[serde(default, skip_serializing_if = "Option::is_none")] pub manual_routes: Option>, @@ -33,6 +37,8 @@ impl Default for CoreRuntimeConfig { acl: AclRuleConfig::default(), dhcp_ipv4: false, dhcp_ipv4_cidr: None, + dhcp_ipv6: false, + dhcp_ipv6_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 8a1ed6c6..436419ef 100644 --- a/easytier-core/src/config/toml.rs +++ b/easytier-core/src/config/toml.rs @@ -64,6 +64,17 @@ pub trait ConfigLoader: Send + Sync { self.set_dhcp_config(DhcpConfig::Enabled(dhcp)); } + fn get_dhcpv6_config(&self) -> DhcpConfig; + fn set_dhcpv6_config(&self, dhcp: DhcpConfig); + + fn get_dhcpv6(&self) -> bool { + self.get_dhcpv6_config().enabled() + } + + fn set_dhcpv6(&self, dhcp: bool) { + self.set_dhcpv6_config(DhcpConfig::Enabled(dhcp)); + } + fn add_proxy_cidr( &self, cidr: cidr::Ipv4Cidr, @@ -406,6 +417,7 @@ struct Config { ipv6_public_addr_auto: Option, ipv6_public_addr_prefix: Option, dhcp: Option, + dhcpv6: Option>, network_identity: Option, listeners: Option>, mapped_listeners: Option>, @@ -741,6 +753,14 @@ impl ConfigLoader for TomlConfig { self.config.lock().unwrap().dhcp = Some(dhcp); } + fn get_dhcpv6_config(&self) -> DhcpConfig { + self.config.lock().unwrap().dhcpv6.unwrap_or_default() + } + + fn set_dhcpv6_config(&self, dhcp: DhcpConfig) { + self.config.lock().unwrap().dhcpv6 = Some(dhcp); + } + fn add_proxy_cidr( &self, cidr: cidr::Ipv4Cidr, @@ -1107,6 +1127,42 @@ mod tests { } } + #[test] + fn dhcpv6_config_round_trip_and_validation() { + assert!(!TomlConfig::default().get_dhcpv6()); + for value in [ + "true", + "false", + "'fd12:3456::/64'", + "'fd12::/126'", + "'2001:db8::/48'", + ] { + let config = + TomlConfig::new_from_str(&format!("dhcp = true\ndhcpv6 = {value}")).unwrap(); + let exported = config.dump(); + let restored = TomlConfig::new_from_str(&exported).unwrap(); + assert_eq!(restored.get_dhcpv6_config(), config.get_dhcpv6_config()); + assert!(restored.get_dhcp()); + assert!(exported.contains("dhcp = true")); + } + for value in [ + "yes", + "10.0.0.0/24", + "fd12::1/64", + "fd12::/127", + "fd12::/128", + "::/0", + "::/64", + "fe80::/64", + "ff00::/8", + ] { + assert!( + TomlConfig::new_from_str(&format!("dhcpv6 = '{value}'")).is_err(), + "accepted {value}" + ); + } + } + #[test] fn dhcp_rejects_invalid_or_unusable_subnets() { for value in [ @@ -1131,18 +1187,20 @@ mod tests { #[test] fn parse_error_preserves_source_and_location() { - let error = - TomlConfig::new_from_str_with_source("fixture.toml", "dhcp = \"yes\"").unwrap_err(); - let display = error.to_string(); + for field in ["dhcp", "dhcpv6"] { + let input = format!("{field} = \"yes\""); + let error = TomlConfig::new_from_str_with_source("fixture.toml", &input).unwrap_err(); + let display = error.to_string(); - assert!(display.contains("fixture.toml")); - assert!(display.contains("dhcp = \"yes\"")); - assert!(display.contains("invalid DHCP IPv4 CIDR")); - assert!( - error - .chain() - .any(|cause| cause.downcast_ref::().is_some()) - ); + assert!(display.contains("fixture.toml")); + assert!(display.contains(&input)); + assert!(display.contains("invalid DHCP CIDR")); + assert!( + error + .chain() + .any(|cause| cause.downcast_ref::().is_some()) + ); + } } #[cfg(feature = "config-write")] @@ -1948,13 +2006,16 @@ mod diagnostic_compatibility_tests { #[test] fn non_ascii_before_typed_error_keeps_byte_location() { - let error = TomlConfig::new_from_str("hostname = \"节点\"\ndhcp = \"yes\"") - .unwrap_err() - .to_string(); + for field in ["dhcp", "dhcpv6"] { + let invalid_field = format!("{field} = \"yes\""); + let error = TomlConfig::new_from_str(&format!("hostname = \"节点\"\n{invalid_field}")) + .unwrap_err() + .to_string(); - assert!(error.contains("dhcp = \"yes\"")); - assert!(error.contains('^')); - assert!(error.contains("invalid DHCP IPv4 CIDR")); + assert!(error.contains(&invalid_field)); + assert!(error.contains('^')); + assert!(error.contains("invalid DHCP CIDR")); + } } #[cfg(feature = "rich-config-errors")] diff --git a/easytier-core/src/gateway/dhcp.rs b/easytier-core/src/gateway/dhcp.rs index 588634e3..007651c7 100644 --- a/easytier-core/src/gateway/dhcp.rs +++ b/easytier-core/src/gateway/dhcp.rs @@ -1,7 +1,7 @@ use std::{collections::HashSet, sync::Arc, time::Duration}; use async_trait::async_trait; -use cidr::Ipv4Inet; +use cidr::{Inet, Ipv4Inet, Ipv6Inet}; use rand::Rng; use tokio_util::task::AbortOnDropHandle; @@ -9,16 +9,16 @@ use crate::{ config::IpPrefix, config::runtime::CoreRuntimeConfigStore, peers::peer_manager::PeerManagerCore, }; -#[cfg(feature = "dhcp-ipv4")] +#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] use tokio::sync::Mutex; #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum DhcpIpv4Decision { +pub enum DhcpDecision { WaitForPeers, Unchanged, Change { - previous: Option, - next: Option, + previous: Option, + next: Option, }, } @@ -26,164 +26,242 @@ pub enum DhcpIpv4Decision { // network's first node falls back to the bootstrap subnet. const BOOTSTRAP_WAIT_ROUNDS: u32 = 2; -#[derive(Debug, Default)] -pub struct DhcpIpv4Allocator { - default_subnet: Option, - current: Option, - no_ipv4_rounds: u32, +#[derive(Debug)] +pub struct DhcpAllocator { + default_subnet: Option, + current: Option, + no_address_rounds: u32, } -impl DhcpIpv4Allocator { - pub fn new(default_subnet: Ipv4Inet) -> Self { +impl Default for DhcpAllocator { + fn default() -> Self { + Self { + default_subnet: None, + current: None, + no_address_rounds: 0, + } + } +} + +impl DhcpAllocator { + pub fn new(default_subnet: I) -> Self { Self { default_subnet: Some(default_subnet), current: None, - no_ipv4_rounds: 0, + no_address_rounds: 0, } } - pub fn current(&self) -> Option { + pub fn current(&self) -> Option { self.current } pub fn reset(&mut self) { self.current = None; - self.no_ipv4_rounds = 0; + self.no_address_rounds = 0; } - pub fn commit(&mut self, next: Option) { + pub fn commit(&mut self, next: Option) { self.current = next; } - pub fn evaluate( + fn evaluate_with( &mut self, has_routes: bool, - used_ipv4: &HashSet, - ) -> DhcpIpv4Decision { - self.evaluate_with_rng(has_routes, used_ipv4, &mut rand::thread_rng()) - } - - fn evaluate_with_rng( - &mut self, - has_routes: bool, - used_ipv4: &HashSet, - rng: &mut impl Rng, - ) -> DhcpIpv4Decision { + used: &HashSet, + usable_subnet: impl Fn(I) -> bool, + pick: impl FnOnce(I, bool) -> Option, + ) -> DhcpDecision { if !has_routes && self.default_subnet.is_none() { - return DhcpIpv4Decision::WaitForPeers; + return DhcpDecision::WaitForPeers; } - let preferred = used_ipv4 + let advertised = used .iter() - .filter(|subnet| { - self.current - .is_some_and(|current| current.network() == subnet.network()) - }) - .min_by_key(|subnet| (subnet.first_address(), subnet.network_length())); - let advertised = preferred.or_else(|| { - used_ipv4 - .iter() - .min_by_key(|subnet| (subnet.first_address(), subnet.network_length())) - }); + .filter(|subnet| usable_subnet(**subnet)) + .min_by_key(|subnet| { + ( + self.current + .is_none_or(|current| current.network() != subnet.network()), + subnet.first_address(), + subnet.network_length(), + ) + }); let subnet = if let Some(subnet) = advertised { - self.no_ipv4_rounds = 0; + self.no_address_rounds = 0; *subnet - } else if self.current.is_some() { - return DhcpIpv4Decision::Unchanged; + } else if let Some(current) = self.current { + current } else { let Some(default_subnet) = self.default_subnet else { - return DhcpIpv4Decision::WaitForPeers; + return DhcpDecision::WaitForPeers; }; - self.no_ipv4_rounds = self.no_ipv4_rounds.saturating_add(1); - if self.no_ipv4_rounds <= BOOTSTRAP_WAIT_ROUNDS { - return DhcpIpv4Decision::WaitForPeers; + self.no_address_rounds = self.no_address_rounds.saturating_add(1); + if self.no_address_rounds <= BOOTSTRAP_WAIT_ROUNDS { + return DhcpDecision::WaitForPeers; } default_subnet }; if let Some(current) = self.current && current.network() == subnet.network() - && !used_ipv4.contains(¤t) + && !used.iter().any(|peer| peer.address() == current.address()) { - return DhcpIpv4Decision::Unchanged; + return DhcpDecision::Unchanged; } - let first = u32::from(subnet.first_address()); - let span = u32::from(subnet.last_address()) - first; - // Spread simultaneous bootstrap allocations and conflict retries across - // the subnet. Existing route announcements still detect collisions. - let offset = if span > 1 && (used_ipv4.is_empty() || self.current.is_some()) { - rng.gen_range(1..span) - } else { - 1 - }; - let next = (offset..span).chain(1..offset).find_map(|offset| { - let candidate = Ipv4Inet::new((first + offset).into(), subnet.network_length()).ok()?; - (!used_ipv4.contains(&candidate)).then_some(candidate) - }); + let next = pick(subnet, self.current.is_some()); if self.current == next { - return DhcpIpv4Decision::Unchanged; + return DhcpDecision::Unchanged; } - DhcpIpv4Decision::Change { + DhcpDecision::Change { previous: self.current, next, } } } +impl DhcpAllocator { + pub fn evaluate( + &mut self, + has_routes: bool, + used: &HashSet, + ) -> DhcpDecision { + self.evaluate_with_rng(has_routes, used, &mut rand::thread_rng()) + } + + fn evaluate_with_rng( + &mut self, + has_routes: bool, + used: &HashSet, + rng: &mut impl Rng, + ) -> DhcpDecision { + self.evaluate_with( + has_routes, + used, + |_| true, + |subnet, retry| { + let first = u32::from(subnet.first_address()); + let span = u32::from(subnet.last_address()) - first; + // Spread simultaneous bootstrap allocations and conflict retries across + // the subnet. Existing route announcements still detect collisions. + let offset = if span > 1 && (used.is_empty() || retry) { + rng.gen_range(1..span) + } else { + 1 + }; + (offset..span).chain(1..offset).find_map(|offset| { + let candidate = + Ipv4Inet::new((first + offset).into(), subnet.network_length()).ok()?; + (!used.contains(&candidate)).then_some(candidate) + }) + }, + ) + } +} + +impl DhcpAllocator { + pub fn evaluate( + &mut self, + has_routes: bool, + used: &HashSet, + ) -> DhcpDecision { + self.evaluate_with_rng(has_routes, used, &mut rand::thread_rng()) + } + + fn evaluate_with_rng( + &mut self, + has_routes: bool, + used: &HashSet, + rng: &mut impl Rng, + ) -> DhcpDecision { + let used_ips = used + .iter() + .map(|peer| peer.address()) + .collect::>(); + self.evaluate_with( + has_routes, + used, + |subnet| crate::config::valid_ipv6_subnet(subnet.network()), + |subnet, _retry| { + let first = u128::from(subnet.first_address()); + let span = u128::from(subnet.last_address()) - first; + if span <= 1 { + return None; + } + // Reserve the subnet endpoints. In particular, older EasyTier peers + // flood packets addressed to the last address of an IPv6 subnet. + let mut offset = rng.gen_range(1..span); + // There can be at most used_ips.len() occupied candidates. This + // bound avoids ever walking an entire IPv6 subnet. + for _ in 0..(used_ips.len() as u128 + 1).min(span - 1) { + let candidate = + Ipv6Inet::new((first + offset).into(), subnet.network_length()).ok()?; + if !used_ips.contains(&candidate.address()) { + return Some(candidate); + } + offset = if offset + 1 == span { 1 } else { offset + 1 }; + } + None + }, + ) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct DhcpAddresses { + pub ipv4: Option, + pub ipv6: Option, +} + #[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct DhcpIpv4RouteSnapshot { +pub struct DhcpRouteSnapshot { pub has_routes: bool, pub used_ipv4: HashSet, + pub used_ipv6: HashSet, } #[async_trait] -pub trait DhcpIpv4RouteSource: Send + Sync + 'static { - async fn dhcp_ipv4_route_snapshot(&self) -> DhcpIpv4RouteSnapshot; +pub trait DhcpRouteSource: Send + Sync + 'static { + async fn dhcp_route_snapshot(&self) -> DhcpRouteSnapshot; } #[async_trait] -impl DhcpIpv4RouteSource for PeerManagerCore { - async fn dhcp_ipv4_route_snapshot(&self) -> DhcpIpv4RouteSnapshot { +impl DhcpRouteSource for PeerManagerCore { + async fn dhcp_route_snapshot(&self) -> DhcpRouteSnapshot { let routes = self.get_route().list_routes().await; - let has_routes = !routes.is_empty(); - let used_ipv4 = routes - .into_iter() - .filter_map(|route| route.ipv4_addr.map(Into::into)) - .collect(); - DhcpIpv4RouteSnapshot { - has_routes, - used_ipv4, + DhcpRouteSnapshot { + has_routes: !routes.is_empty(), + used_ipv4: routes + .iter() + .filter_map(|route| route.ipv4_addr.map(Into::into)) + .collect(), + used_ipv6: routes + .iter() + .filter_map(|route| route.ipv6_addr.map(Ipv6Inet::from)) + .collect(), } } } #[async_trait] -pub trait DhcpIpv4Host: Send + Sync + 'static { +pub trait DhcpHost: Send + Sync + 'static { fn take_interface_closed(&self) -> bool; - async fn apply_dhcp_ipv4( - &self, - previous: Option, - next: Option, - ) -> DhcpIpv4ApplyOutcome; + /// Only enabled DHCP families are supplied. The Host merges static addresses + /// when configuring its interface, without publishing those static snapshots. + async fn apply_dhcp(&self, next: DhcpAddresses) -> DhcpApplyOutcome; - fn publish_dhcp_ipv4( - &self, - _previous: Option, - _requested: Option, - _actual: Option, - ) { - } + fn publish_dhcp(&self, _previous: DhcpAddresses, _actual: DhcpAddresses) {} } -#[cfg(feature = "dhcp-ipv4")] -pub(crate) struct DhcpIpv4Runtime { +#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] +pub(crate) struct DhcpRuntime { task: Mutex>>, } -#[cfg(feature = "dhcp-ipv4")] -impl DhcpIpv4Runtime { +#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] +impl DhcpRuntime { pub(crate) fn new() -> Self { Self { task: Mutex::new(None), @@ -192,13 +270,13 @@ impl DhcpIpv4Runtime { pub(crate) async fn start( &self, - route_source: Arc, + route_source: Arc, runtime_config: CoreRuntimeConfigStore, - host: Arc, + host: Arc, ) { let mut task = self.task.lock().await; if task.is_none() { - task.replace(DhcpIpv4Service::new(route_source, runtime_config, host).start()); + task.replace(DhcpService::new(route_source, runtime_config, host).start()); } } @@ -207,11 +285,11 @@ impl DhcpIpv4Runtime { } } -pub struct DhcpIpv4ApplyPermit { +pub struct DhcpApplyPermit { _guard: Box, } -impl DhcpIpv4ApplyPermit { +impl DhcpApplyPermit { pub fn new(guard: impl Send + 'static) -> Self { Self { _guard: Box::new(guard), @@ -219,14 +297,14 @@ impl DhcpIpv4ApplyPermit { } } -pub struct DhcpIpv4ApplyOutcome { - pub actual: Option, +pub struct DhcpApplyOutcome { + pub actual: DhcpAddresses, pub result: anyhow::Result<()>, - permit: Option, + permit: Option, } -impl DhcpIpv4ApplyOutcome { - pub fn applied(actual: Option) -> Self { +impl DhcpApplyOutcome { + pub fn applied(actual: DhcpAddresses) -> Self { Self { actual, result: Ok(()), @@ -234,7 +312,7 @@ impl DhcpIpv4ApplyOutcome { } } - pub fn failed(actual: Option, error: impl Into) -> Self { + pub fn failed(actual: DhcpAddresses, error: impl Into) -> Self { Self { actual, result: Err(error.into()), @@ -242,84 +320,143 @@ impl DhcpIpv4ApplyOutcome { } } - pub fn with_permit(mut self, permit: DhcpIpv4ApplyPermit) -> Self { + pub fn with_permit(mut self, permit: DhcpApplyPermit) -> Self { self.permit = Some(permit); self } } -pub struct DhcpIpv4Service { - operation: tokio::sync::Mutex<()>, - allocator: std::sync::Mutex, - route_source: Arc, - runtime_config: CoreRuntimeConfigStore, - host: Arc, +struct DhcpState { + ipv4: Option>, + ipv6: Option>, + needs_apply: bool, } -impl DhcpIpv4Service { +impl DhcpState { + fn current(&self) -> DhcpAddresses { + DhcpAddresses { + ipv4: self.ipv4.as_ref().and_then(|allocator| allocator.current()), + ipv6: self.ipv6.as_ref().and_then(|allocator| allocator.current()), + } + } +} + +pub struct DhcpService { + operation: tokio::sync::Mutex<()>, + state: std::sync::Mutex, + route_source: Arc, + runtime_config: CoreRuntimeConfigStore, + host: Arc, +} + +impl DhcpService { pub fn new( - route_source: Arc, + route_source: Arc, runtime_config: CoreRuntimeConfigStore, - host: Arc, + 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") - }); + let config = runtime_config.snapshot(); + let ipv4 = config.services.dhcp_ipv4.then(|| { + DhcpAllocator::new( + config + .services + .dhcp_ipv4_cidr + .map(|cidr| cidr.first()) + .unwrap_or_else(|| "10.126.126.0/24".parse().expect("valid bootstrap subnet")), + ) + }); + let ipv6 = config.services.dhcp_ipv6.then(|| { + DhcpAllocator::new( + config + .services + .dhcp_ipv6_cidr + .map(|cidr| cidr.first()) + .unwrap_or_else(|| { + "fd6b:8c2d:91e4::/64" + .parse() + .expect("valid bootstrap subnet") + }), + ) + }); Arc::new(Self { operation: tokio::sync::Mutex::new(()), - allocator: std::sync::Mutex::new(DhcpIpv4Allocator::new(default_subnet)), + state: std::sync::Mutex::new(DhcpState { + ipv4, + ipv6, + needs_apply: true, + }), route_source, runtime_config, host, }) } - pub fn current(&self) -> Option { - self.allocator.lock().unwrap().current() + pub fn current(&self) -> DhcpAddresses { + self.state.lock().unwrap().current() } pub async fn reconcile_once(&self) -> bool { let _operation = self.operation.lock().await; - if self.host.take_interface_closed() { - self.allocator.lock().unwrap().reset(); - } - let snapshot = self.route_source.dhcp_ipv4_route_snapshot().await; - let decision = self - .allocator - .lock() - .unwrap() - .evaluate(snapshot.has_routes, &snapshot.used_ipv4); - - let DhcpIpv4Decision::Change { previous, next } = decision else { - return snapshot.has_routes; + let interface_closed = self.host.take_interface_closed(); + let snapshot = self.route_source.dhcp_route_snapshot().await; + let (previous, next, manage_ipv4, manage_ipv6) = { + let mut state = self.state.lock().unwrap(); + let previous = state.current(); + let mut next = previous; + if let Some(allocator) = &mut state.ipv4 + && let DhcpDecision::Change { next: address, .. } = + allocator.evaluate(snapshot.has_routes, &snapshot.used_ipv4) + { + next.ipv4 = address; + } + if let Some(allocator) = &mut state.ipv6 + && let DhcpDecision::Change { next: address, .. } = + allocator.evaluate(snapshot.has_routes, &snapshot.used_ipv6) + { + next.ipv6 = address; + } + if previous == next && !state.needs_apply && !interface_closed { + return snapshot.has_routes; + } + // Also retain the retry on cancellation while awaiting the Host. + state.needs_apply = true; + (previous, next, state.ipv4.is_some(), state.ipv6.is_some()) }; - tracing::debug!(?previous, ?next, "DHCP IPv4 reconciliation applying change"); - let outcome = self.host.apply_dhcp_ipv4(previous, next).await; - let DhcpIpv4ApplyOutcome { + tracing::debug!(?previous, ?next, "DHCP reconciliation applying change"); + let DhcpApplyOutcome { actual, result, permit, - } = outcome; + } = self.host.apply_dhcp(next).await; self.runtime_config.update_peer_with(|peer| { - peer.runtime.core.routes.ipv4 = actual.map(|actual| IpPrefix { - address: actual.address().into(), - prefix_len: actual.network_length(), - }); + if manage_ipv4 { + peer.runtime.core.routes.ipv4 = actual.ipv4.map(|address| IpPrefix { + address: address.address().into(), + prefix_len: address.network_length(), + }); + } + if manage_ipv6 { + peer.runtime.core.routes.ipv6 = actual.ipv6.map(|address| IpPrefix { + address: address.address().into(), + prefix_len: address.network_length(), + }); + } }); match result { Ok(()) => { - self.allocator.lock().unwrap().commit(actual); - self.host.publish_dhcp_ipv4(previous, next, actual); - } - Err(err) => { - tracing::error!(?previous, ?next, ?actual, ?err, "DHCP IPv4 apply failed"); + { + let mut state = self.state.lock().unwrap(); + if let Some(allocator) = &mut state.ipv4 { + allocator.commit(actual.ipv4); + } + if let Some(allocator) = &mut state.ipv6 { + allocator.commit(actual.ipv6); + } + state.needs_apply = false; + } + self.host.publish_dhcp(previous, actual); } + Err(err) => tracing::error!(?previous, ?next, ?actual, ?err, "DHCP apply failed"), } drop(permit); snapshot.has_routes @@ -350,19 +487,19 @@ mod tests { use super::*; + type Ipv4Allocator = DhcpAllocator; + struct StaticRouteSource { - snapshot: Mutex, + snapshot: Mutex, } #[async_trait] - impl DhcpIpv4RouteSource for StaticRouteSource { - async fn dhcp_ipv4_route_snapshot(&self) -> DhcpIpv4RouteSnapshot { + impl DhcpRouteSource for StaticRouteSource { + async fn dhcp_route_snapshot(&self) -> DhcpRouteSnapshot { self.snapshot.lock().unwrap().clone() } } - type PublishedIpv4Route = (Option, Option, Option); - #[derive(Default)] struct RecordingHost { interface_closed: AtomicBool, @@ -372,8 +509,9 @@ mod tests { published_with_permit: AtomicBool, runtime_config: Mutex>, published_runtime_ipv4: Mutex>>, - changes: Mutex, Option)>>, - published: Mutex>, + changes: Mutex>, + static_ipv6_patch: Mutex>, + published: Mutex>, } struct RecordingPermit(Arc); @@ -385,37 +523,38 @@ mod tests { } #[async_trait] - impl DhcpIpv4Host for RecordingHost { + impl DhcpHost for RecordingHost { fn take_interface_closed(&self) -> bool { self.interface_closed.swap(false, Ordering::AcqRel) } - async fn apply_dhcp_ipv4( - &self, - previous: Option, - next: Option, - ) -> DhcpIpv4ApplyOutcome { - self.changes.lock().unwrap().push((previous, next)); + async fn apply_dhcp(&self, next: DhcpAddresses) -> DhcpApplyOutcome { + self.changes.lock().unwrap().push(next); + if let Some(address) = self.static_ipv6_patch.lock().unwrap().take() { + self.runtime_config + .lock() + .unwrap() + .as_ref() + .unwrap() + .update_peer_with(|peer| { + peer.runtime.core.routes.ipv6 = Some(address); + }); + } let mut outcome = if self.fail_apply.load(Ordering::Acquire) { - DhcpIpv4ApplyOutcome::failed(None, anyhow::anyhow!("apply failed")) + DhcpApplyOutcome::failed(DhcpAddresses::default(), anyhow::anyhow!("apply failed")) } else { - DhcpIpv4ApplyOutcome::applied(next) + DhcpApplyOutcome::applied(next) }; if self.hold_apply_permit.load(Ordering::Acquire) { assert!(!self.permit_held.swap(true, Ordering::AcqRel)); - outcome = outcome.with_permit(DhcpIpv4ApplyPermit::new(RecordingPermit( + outcome = outcome.with_permit(DhcpApplyPermit::new(RecordingPermit( self.permit_held.clone(), ))); } outcome } - fn publish_dhcp_ipv4( - &self, - previous: Option, - requested: Option, - actual: Option, - ) { + fn publish_dhcp(&self, previous: DhcpAddresses, actual: DhcpAddresses) { self.published_with_permit .store(self.permit_held.load(Ordering::Acquire), Ordering::Release); if let Some(runtime_config) = self.runtime_config.lock().unwrap().as_ref() { @@ -430,67 +569,84 @@ mod tests { .clone(), ); } - self.published - .lock() - .unwrap() - .push((previous, requested, actual)); + self.published.lock().unwrap().push((previous, actual)); } } fn service( - snapshot: DhcpIpv4RouteSnapshot, + snapshot: DhcpRouteSnapshot, host: Arc, cidr: Option<&str>, - ) -> (Arc, CoreRuntimeConfigStore) { - let runtime_config = CoreRuntimeConfigStore::new( + ) -> (Arc, CoreRuntimeConfigStore) { + let (service, runtime_config, routes) = service_with_config( + host, crate::config::runtime::CoreRuntimeConfig { + dhcp_ipv4: true, 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()); - let service = DhcpIpv4Service::new( - Arc::new(StaticRouteSource { - snapshot: Mutex::new(snapshot), - }), - runtime_config.clone(), - host, ); + *routes.snapshot.lock().unwrap() = snapshot; (service, runtime_config) } + fn service_with_config( + host: Arc, + config: crate::config::runtime::CoreRuntimeConfig, + ) -> ( + Arc, + CoreRuntimeConfigStore, + Arc, + ) { + let runtime = CoreRuntimeConfigStore::new(config, Arc::new(Default::default())); + *host.runtime_config.lock().unwrap() = Some(runtime.clone()); + let routes = Arc::new(StaticRouteSource { + snapshot: Mutex::new(Default::default()), + }); + ( + DhcpService::new(routes.clone(), runtime.clone(), host), + runtime, + routes, + ) + } + + async fn bootstrap(service: &DhcpService) { + for _ in 0..=BOOTSTRAP_WAIT_ROUNDS { + service.reconcile_once().await; + } + } + #[test] fn waits_until_at_least_one_route_exists() { - let mut allocator = DhcpIpv4Allocator::default(); + let mut allocator = Ipv4Allocator::default(); assert_eq!( allocator.evaluate(false, &HashSet::new()), - DhcpIpv4Decision::WaitForPeers + DhcpDecision::WaitForPeers ); } #[test] fn does_not_fall_back_to_a_builtin_subnet_without_assigned_ipv4() { - let mut allocator = DhcpIpv4Allocator::default(); + let mut allocator = Ipv4Allocator::default(); assert_eq!( allocator.evaluate(true, &HashSet::new()), - DhcpIpv4Decision::WaitForPeers + DhcpDecision::WaitForPeers ); } #[test] fn bootstraps_default_subnet_after_bounded_wait() { - let mut allocator = DhcpIpv4Allocator::new("10.126.126.0/24".parse().unwrap()); + let mut allocator = Ipv4Allocator::new("10.126.126.0/24".parse().unwrap()); for _ in 0..2 { assert_eq!( allocator.evaluate(true, &HashSet::new()), - DhcpIpv4Decision::WaitForPeers + DhcpDecision::WaitForPeers ); } - let DhcpIpv4Decision::Change { + let DhcpDecision::Change { previous: None, next: Some(address), } = allocator.evaluate(true, &HashSet::new()) @@ -507,12 +663,12 @@ mod tests { #[test] fn announced_peer_subnet_takes_priority_over_bootstrap_subnet() { - let mut allocator = DhcpIpv4Allocator::new("10.126.126.0/24".parse().unwrap()); + let mut allocator = Ipv4Allocator::new("10.126.126.0/24".parse().unwrap()); let used = HashSet::from(["198.18.106.5/24".parse().unwrap()]); assert_eq!( allocator.evaluate(true, &used), - DhcpIpv4Decision::Change { + DhcpDecision::Change { previous: None, next: Some("198.18.106.1/24".parse().unwrap()), } @@ -521,14 +677,14 @@ mod tests { #[test] fn keeps_current_address_when_peers_stop_announcing_ipv4() { - let mut allocator = DhcpIpv4Allocator::new("10.126.126.0/24".parse().unwrap()); + let mut allocator = Ipv4Allocator::new("10.126.126.0/24".parse().unwrap()); let current = "198.18.106.1/24".parse().unwrap(); allocator.commit(Some(current)); for _ in 0..BOOTSTRAP_WAIT_ROUNDS + 2 { assert_eq!( allocator.evaluate(true, &HashSet::new()), - DhcpIpv4Decision::Unchanged + DhcpDecision::Unchanged ); assert_eq!(allocator.current(), Some(current)); } @@ -539,7 +695,7 @@ mod tests { use rand::SeedableRng; let subnet = "10.126.126.0/24".parse().unwrap(); let mut allocators = (0..32) - .map(|_| DhcpIpv4Allocator::new(subnet)) + .map(|_| Ipv4Allocator::new(subnet)) .collect::>(); let mut rng = rand::rngs::StdRng::seed_from_u64(42); for round in 0..30 { @@ -562,7 +718,7 @@ mod tests { }) .collect::>(); for (allocator, decision) in allocators.iter_mut().zip(decisions) { - if let DhcpIpv4Decision::Change { next, .. } = decision { + if let DhcpDecision::Change { next, .. } = decision { allocator.commit(next); } } @@ -581,19 +737,19 @@ mod tests { #[test] fn keeps_advertised_current_subnet_and_uses_deterministic_fallback() { - let mut allocator = DhcpIpv4Allocator::default(); + let mut allocator = Ipv4Allocator::default(); allocator.commit(Some("10.2.0.8/24".parse().unwrap())); let used = HashSet::from([ "10.1.0.2/24".parse().unwrap(), "10.2.0.2/24".parse().unwrap(), ]); for _ in 0..20 { - assert_eq!(allocator.evaluate(true, &used), DhcpIpv4Decision::Unchanged); + assert_eq!(allocator.evaluate(true, &used), DhcpDecision::Unchanged); } allocator.reset(); assert_eq!( allocator.evaluate(true, &used), - DhcpIpv4Decision::Change { + DhcpDecision::Change { previous: None, next: Some("10.1.0.1/24".parse().unwrap()), } @@ -602,23 +758,23 @@ mod tests { #[test] fn keeps_current_address_when_it_is_free_in_the_selected_subnet() { - let mut allocator = DhcpIpv4Allocator::default(); + let mut allocator = Ipv4Allocator::default(); allocator.commit(Some("10.1.2.8/24".parse().unwrap())); let used = HashSet::from(["10.1.2.2/24".parse().unwrap()]); - assert_eq!(allocator.evaluate(true, &used), DhcpIpv4Decision::Unchanged); + assert_eq!(allocator.evaluate(true, &used), DhcpDecision::Unchanged); } #[test] fn selects_an_unused_host_after_a_conflict() { - let mut allocator = DhcpIpv4Allocator::default(); + let mut allocator = Ipv4Allocator::default(); allocator.commit(Some("10.1.2.1/24".parse().unwrap())); let used = HashSet::from([ "10.1.2.1/24".parse().unwrap(), "10.1.2.2/24".parse().unwrap(), ]); - let DhcpIpv4Decision::Change { + let DhcpDecision::Change { previous, next: Some(next), } = allocator.evaluate(true, &used) @@ -632,7 +788,7 @@ mod tests { #[test] fn reset_forgets_the_previous_interface_address() { - let mut allocator = DhcpIpv4Allocator::default(); + let mut allocator = Ipv4Allocator::default(); allocator.commit(Some("10.1.2.8/24".parse().unwrap())); allocator.reset(); @@ -668,23 +824,28 @@ mod tests { async fn service_prefers_peer_subnet_over_configured_bootstrap_subnet() { let host = Arc::new(RecordingHost::default()); let (service, _runtime_config) = service( - DhcpIpv4RouteSnapshot { + DhcpRouteSnapshot { has_routes: true, used_ipv4: HashSet::from(["10.20.0.1/16".parse().unwrap()]), + ..Default::default() }, host, Some("192.168.100.0/24"), ); service.reconcile_once().await; - assert_eq!(service.current(), Some("10.20.0.2/16".parse().unwrap())); + assert_eq!( + service.current().ipv4, + 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 { + DhcpRouteSnapshot { has_routes, used_ipv4: HashSet::new(), + ..Default::default() }, host.clone(), cidr, @@ -694,9 +855,12 @@ mod tests { assert_eq!(service.reconcile_once().await, has_routes); } - assert_eq!(service.current(), None); - assert!(host.changes.lock().unwrap().is_empty()); - assert!(host.published.lock().unwrap().is_empty()); + assert_eq!(service.current().ipv4, None); + assert_eq!(*host.changes.lock().unwrap(), [DhcpAddresses::default()]); + assert_eq!( + *host.published.lock().unwrap(), + [(DhcpAddresses::default(), DhcpAddresses::default())] + ); assert!( runtime_config .snapshot() @@ -709,7 +873,7 @@ mod tests { ); assert_eq!(service.reconcile_once().await, has_routes); - let address = service.current().expect("bootstrap address"); + let address = service.current().ipv4.expect("bootstrap address"); assert_eq!( address.network(), cidr.unwrap_or("10.126.126.0/24") @@ -719,7 +883,16 @@ mod tests { ); 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!( + *host.changes.lock().unwrap(), + [ + DhcpAddresses::default(), + DhcpAddresses { + ipv4: Some(address), + ipv6: None, + } + ] + ); assert_eq!( runtime_config.snapshot().peer.runtime.core.routes.ipv4, Some(IpPrefix { @@ -728,17 +901,18 @@ mod tests { }) ); assert_eq!(service.reconcile_once().await, has_routes); - assert_eq!(service.current(), Some(address)); - assert_eq!(host.changes.lock().unwrap().len(), 1); + assert_eq!(service.current().ipv4, Some(address)); + assert_eq!(host.changes.lock().unwrap().len(), 2); } #[tokio::test] async fn service_commits_only_after_host_apply_succeeds() { let host = Arc::new(RecordingHost::default()); let (service, runtime_config) = service( - DhcpIpv4RouteSnapshot { + DhcpRouteSnapshot { has_routes: true, used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), + ..Default::default() }, host.clone(), None, @@ -746,22 +920,19 @@ mod tests { assert!(service.reconcile_once().await); - assert_eq!(service.current(), Some("198.18.0.1/24".parse().unwrap())); - assert_eq!( - *host.changes.lock().unwrap(), - [(None, Some("198.18.0.1/24".parse().unwrap()))] - ); + let addresses = DhcpAddresses { + ipv4: Some("198.18.0.1/24".parse().unwrap()), + ipv6: None, + }; + assert_eq!(service.current(), addresses); + assert_eq!(*host.changes.lock().unwrap(), [addresses]); assert_eq!( runtime_config.snapshot().peer.runtime.core.routes.ipv4, Some(IpPrefix::new("198.18.0.1".parse().unwrap(), 24).unwrap()) ); assert_eq!( *host.published.lock().unwrap(), - [( - None, - Some("198.18.0.1/24".parse().unwrap()), - Some("198.18.0.1/24".parse().unwrap()) - )] + [(DhcpAddresses::default(), addresses)] ); } @@ -770,9 +941,10 @@ mod tests { let host = Arc::new(RecordingHost::default()); host.hold_apply_permit.store(true, Ordering::Release); let (service, _runtime_config) = service( - DhcpIpv4RouteSnapshot { + DhcpRouteSnapshot { has_routes: true, used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), + ..Default::default() }, host.clone(), None, @@ -794,9 +966,10 @@ mod tests { let host = Arc::new(RecordingHost::default()); host.fail_apply.store(true, Ordering::Release); let (service, runtime_config) = service( - DhcpIpv4RouteSnapshot { + DhcpRouteSnapshot { has_routes: true, used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), + ..Default::default() }, host.clone(), None, @@ -805,7 +978,7 @@ mod tests { service.reconcile_once().await; service.reconcile_once().await; - assert_eq!(service.current(), None); + assert_eq!(service.current().ipv4, None); assert_eq!(host.changes.lock().unwrap().len(), 2); assert_eq!( runtime_config.snapshot().peer.runtime.core.routes.ipv4, @@ -815,12 +988,13 @@ mod tests { } #[tokio::test] - async fn interface_close_resets_previous_allocation_before_reapply() { + async fn interface_close_reapplies_previous_allocation() { let host = Arc::new(RecordingHost::default()); let (service, _runtime_config) = service( - DhcpIpv4RouteSnapshot { + DhcpRouteSnapshot { has_routes: true, used_ipv4: HashSet::from(["198.18.0.2/24".parse().unwrap()]), + ..Default::default() }, host.clone(), None, @@ -830,12 +1004,269 @@ mod tests { service.reconcile_once().await; + let addresses = DhcpAddresses { + ipv4: Some("198.18.0.1/24".parse().unwrap()), + ipv6: None, + }; + assert_eq!(*host.changes.lock().unwrap(), [addresses, addresses]); assert_eq!( - host.changes.lock().unwrap().as_slice(), + *host.published.lock().unwrap(), [ - (None, Some("198.18.0.1/24".parse().unwrap())), - (None, Some("198.18.0.1/24".parse().unwrap())), + (DhcpAddresses::default(), addresses), + (addresses, addresses) ] ); } + + #[tokio::test] + async fn dual_stack_bootstrap_applies_both_addresses_together() { + let host = Arc::new(RecordingHost::default()); + let (service, runtime, _) = service_with_config( + host.clone(), + crate::config::runtime::CoreRuntimeConfig { + dhcp_ipv4: true, + dhcp_ipv6: true, + ..Default::default() + }, + ); + bootstrap(&service).await; + let addresses = service.current(); + assert_eq!( + addresses.ipv4.unwrap().network(), + "10.126.126.0/24".parse().unwrap() + ); + assert_eq!( + addresses.ipv6.unwrap().network(), + "fd6b:8c2d:91e4::/64".parse().unwrap() + ); + let changes = host.changes.lock().unwrap(); + assert_eq!(changes.len(), 2); // First installation, then both leases. + assert_eq!(changes[1], addresses); + let config = runtime.snapshot(); + assert_eq!( + config + .peer + .runtime + .core + .routes + .ipv4 + .as_ref() + .unwrap() + .address, + addresses.ipv4.unwrap().address() + ); + assert_eq!( + config + .peer + .runtime + .core + .routes + .ipv6 + .as_ref() + .unwrap() + .address, + addresses.ipv6.unwrap().address() + ); + } + + #[tokio::test] + async fn ipv6_bootstrap_preserves_static_ipv4() { + for subnet in [ + "fd12:3456::/48", + "fd12:3456::/64", + "fd12:3456::/126", + "2001:db8::/64", + ] { + let host = Arc::new(RecordingHost::default()); + let (service, runtime, _) = service_with_config( + host.clone(), + crate::config::runtime::CoreRuntimeConfig { + dhcp_ipv6: true, + dhcp_ipv6_cidr: Some(subnet.parse().unwrap()), + ..Default::default() + }, + ); + let ipv4 = IpPrefix::new("10.0.0.7".parse().unwrap(), 24).unwrap(); + runtime.update_peer_with(|peer| peer.runtime.core.routes.ipv4 = Some(ipv4.clone())); + bootstrap(&service).await; + let address = service.current().ipv6.unwrap(); + assert_eq!(address.network(), subnet.parse().unwrap()); + assert_ne!(address.address(), address.first_address()); + assert_ne!(address.address(), address.last_address()); + assert!(service.current().ipv4.is_none()); + assert_eq!(runtime.snapshot().peer.runtime.core.routes.ipv4, Some(ipv4)); + } + } + + #[tokio::test] + async fn ipv6_exhaustion_does_not_remove_ipv4_and_recovers_when_an_address_is_free() { + let host = Arc::new(RecordingHost::default()); + let (service, runtime, routes) = service_with_config( + host, + crate::config::runtime::CoreRuntimeConfig { + dhcp_ipv4: true, + dhcp_ipv6: true, + ..Default::default() + }, + ); + *routes.snapshot.lock().unwrap() = DhcpRouteSnapshot { + has_routes: true, + used_ipv4: HashSet::from(["10.20.0.1/24".parse().unwrap()]), + used_ipv6: HashSet::from([ + "fd12::1/126".parse().unwrap(), + "fd12::2/126".parse().unwrap(), + ]), + }; + service.reconcile_once().await; + let ipv4 = service.current().ipv4.unwrap(); + assert!(service.current().ipv6.is_none()); + assert!(runtime.snapshot().peer.runtime.core.routes.ipv4.is_some()); + routes + .snapshot + .lock() + .unwrap() + .used_ipv6 + .remove(&"fd12::2/126".parse().unwrap()); + service.reconcile_once().await; + assert_eq!( + service.current(), + DhcpAddresses { + ipv4: Some(ipv4), + ipv6: Some("fd12::2/126".parse().unwrap()) + } + ); + } + + #[tokio::test] + async fn interface_recovery_retries_unchanged_dual_stack_leases_after_failure() { + let host = Arc::new(RecordingHost::default()); + let (service, runtime, _) = service_with_config( + host.clone(), + crate::config::runtime::CoreRuntimeConfig { + dhcp_ipv4: true, + dhcp_ipv6: true, + ..Default::default() + }, + ); + bootstrap(&service).await; + let addresses = service.current(); + host.interface_closed.store(true, Ordering::Release); + host.fail_apply.store(true, Ordering::Release); + service.reconcile_once().await; + assert_eq!(service.current(), addresses); + assert!(runtime.snapshot().peer.runtime.core.routes.ipv6.is_none()); + host.fail_apply.store(false, Ordering::Release); + service.reconcile_once().await; + assert_eq!(service.current(), addresses); + assert!(runtime.snapshot().peer.runtime.core.routes.ipv4.is_some()); + assert!(runtime.snapshot().peer.runtime.core.routes.ipv6.is_some()); + let changes = host.changes.lock().unwrap(); + assert_eq!(&changes[2..], &[addresses, addresses]); + } + + #[tokio::test] + async fn ipv4_apply_does_not_overwrite_a_concurrent_static_ipv6_patch() { + let host = Arc::new(RecordingHost::default()); + let (service, runtime, _) = service_with_config( + host.clone(), + crate::config::runtime::CoreRuntimeConfig { + dhcp_ipv4: true, + ..Default::default() + }, + ); + let address = IpPrefix::new("fd12::7".parse().unwrap(), 64).unwrap(); + *host.static_ipv6_patch.lock().unwrap() = Some(address.clone()); + service.reconcile_once().await; // Also applies while the allocator waits. + assert_eq!(host.changes.lock().unwrap().len(), 1); + assert_eq!( + runtime.snapshot().peer.runtime.core.routes.ipv6, + Some(address) + ); + } + + #[test] + fn ipv6_simultaneous_conflicts_converge() { + use rand::SeedableRng; + let mut rng = rand::rngs::StdRng::seed_from_u64(42); + let mut allocators = (0..32) + .map(|_| { + let mut allocator = DhcpAllocator::::new("fd12::/64".parse().unwrap()); + allocator.commit(Some("fd12::1/64".parse().unwrap())); + allocator + }) + .collect::>(); + for _ in 0..20 { + let current = allocators + .iter() + .map(|allocator| allocator.current()) + .collect::>(); + for (index, allocator) in allocators.iter_mut().enumerate() { + let used = current + .iter() + .enumerate() + .filter_map(|(peer, address)| (peer != index).then_some(*address).flatten()) + .collect(); + if let DhcpDecision::Change { next, .. } = + allocator.evaluate_with_rng(true, &used, &mut rng) + { + allocator.commit(next); + } + } + if allocators + .iter() + .filter_map(|allocator| allocator.current()) + .collect::>() + .len() + == allocators.len() + { + return; + } + } + panic!("IPv6 conflicts did not converge"); + } + + #[test] + fn ipv6_conflict_detection_ignores_advertised_prefix_length() { + let mut allocator = DhcpAllocator::::new("fd12::/64".parse().unwrap()); + allocator.commit(Some("fd12::1/64".parse().unwrap())); + let used = HashSet::from(["fd12::1/80".parse().unwrap(), "fd12::2/64".parse().unwrap()]); + let DhcpDecision::Change { + next: Some(next), .. + } = allocator.evaluate(true, &used) + else { + panic!("conflict was missed"); + }; + assert_ne!( + next.address(), + "fd12::1".parse::().unwrap() + ); + assert_eq!(next.network_length(), 64); + } + + #[test] + fn ipv6_host_routes_reserve_addresses_without_selecting_a_subnet() { + let mut allocator = DhcpAllocator::::new("fd12::/126".parse().unwrap()); + let used = HashSet::from([ + "fd12::1/128".parse().unwrap(), + "fe80::1/64".parse().unwrap(), + ]); + for _ in 0..BOOTSTRAP_WAIT_ROUNDS { + assert_eq!(allocator.evaluate(true, &used), DhcpDecision::WaitForPeers); + } + assert_eq!( + allocator.evaluate(true, &used), + DhcpDecision::Change { + previous: None, + next: Some("fd12::2/126".parse().unwrap()), + } + ); + allocator.commit(Some("fd12::1/126".parse().unwrap())); + assert_eq!( + allocator.evaluate(true, &used), + DhcpDecision::Change { + previous: Some("fd12::1/126".parse().unwrap()), + next: Some("fd12::2/126".parse().unwrap()), + } + ); + } } diff --git a/easytier-core/src/instance/build_capabilities.rs b/easytier-core/src/instance/build_capabilities.rs index dd7ee736..8f45574a 100644 --- a/easytier-core/src/instance/build_capabilities.rs +++ b/easytier-core/src/instance/build_capabilities.rs @@ -8,6 +8,7 @@ use crate::config::{peers::PeerRuntimeSnapshot, runtime::CoreRuntimeConfig}; use super::CoreInstanceConfig; const DHCP_IPV4_AVAILABLE: bool = cfg!(feature = "dhcp-ipv4"); +const DHCP_IPV6_AVAILABLE: bool = cfg!(feature = "dhcp-ipv6"); const SMOLTCP_GATEWAY_AVAILABLE: bool = cfg!(feature = "proxy-smoltcp-stack"); const PACKET_PROXY_AVAILABLE: bool = cfg!(feature = "proxy-packet"); const PROXY_CIDR_MONITOR_AVAILABLE: bool = cfg!(feature = "proxy-cidr-monitor"); @@ -28,6 +29,7 @@ fn validate_snapshot( ) -> anyhow::Result<()> { let core = &peer.runtime.core; require(DHCP_IPV4_AVAILABLE, runtime.dhcp_ipv4, "DHCP IPv4")?; + require(DHCP_IPV6_AVAILABLE, runtime.dhcp_ipv6, "DHCP IPv6")?; require( SMOLTCP_GATEWAY_AVAILABLE, runtime.gateway.socks5_bind.is_some() || !runtime.gateway.port_forwards.is_empty(), diff --git a/easytier-core/src/instance/config.rs b/easytier-core/src/instance/config.rs index c140fdc6..4fe72848 100644 --- a/easytier-core/src/instance/config.rs +++ b/easytier-core/src/instance/config.rs @@ -210,14 +210,20 @@ impl CoreInstanceConfig { network_name: network_name.clone(), }, routes: RouteConfig { - ipv4: config.get_ipv4().map(|value| IpPrefix { - address: value.address().into(), - prefix_len: value.network_length(), - }), - ipv6: config.get_ipv6().map(|value| IpPrefix { - address: value.address().into(), - prefix_len: value.network_length(), - }), + ipv4: config + .get_ipv4() + .filter(|_| !config.get_dhcp()) + .map(|value| IpPrefix { + address: value.address().into(), + prefix_len: value.network_length(), + }), + ipv6: config + .get_ipv6() + .filter(|_| !config.get_dhcpv6()) + .map(|value| IpPrefix { + address: value.address().into(), + prefix_len: value.network_length(), + }), proxy_networks: proxy_networks .into_iter() .map(|proxy| ProxyNetworkConfig { @@ -297,6 +303,8 @@ impl CoreInstanceConfig { }, dhcp_ipv4: config.get_dhcp(), dhcp_ipv4_cidr: config.get_dhcp_config().cidr(), + dhcp_ipv6: config.get_dhcpv6(), + dhcp_ipv6_cidr: config.get_dhcpv6_config().cidr(), gateway: GatewayRuntimeConfig { socks5_bind, port_forwards: if host.ignore_unsupported_config && !host.gateway_enabled { @@ -408,6 +416,24 @@ mod tests { use super::*; + #[test] + fn dhcp_initial_routes_ignore_static_addresses_of_managed_families() { + for dhcp_ipv4 in [false, true] { + for dhcp_ipv6 in [false, true] { + let config = + TomlConfig::new_from_str("ipv4 = '10.20.0.7/24'\nipv6 = 'fd12::7/64'").unwrap(); + config.set_dhcp(dhcp_ipv4); + config.set_dhcpv6(dhcp_ipv6); + let normalized = CoreInstanceConfig::from_toml(&config).unwrap(); + let routes = &normalized.peer.snapshot.runtime.core.routes; + assert_eq!(routes.ipv4.is_some(), !dhcp_ipv4); + assert_eq!(routes.ipv6.is_some(), !dhcp_ipv6); + assert_eq!(config.get_ipv4().unwrap(), "10.20.0.7/24".parse().unwrap()); + assert_eq!(config.get_ipv6().unwrap(), "fd12::7/64".parse().unwrap()); + } + } + } + #[test] fn tcp_stun_servers_follow_toml_override_rules() { let fallback = TomlConfig::new_from_str( diff --git a/easytier-core/src/instance/lifecycle.rs b/easytier-core/src/instance/lifecycle.rs index ca1617e0..d90133db 100644 --- a/easytier-core/src/instance/lifecycle.rs +++ b/easytier-core/src/instance/lifecycle.rs @@ -6,8 +6,8 @@ use std::sync::{Arc, Weak}; -#[cfg(feature = "dhcp-ipv4")] -use crate::gateway::dhcp::{DhcpIpv4Host, DhcpIpv4RouteSource}; +#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] +use crate::gateway::dhcp::{DhcpHost, DhcpRouteSource}; use super::{CoreInstance, CoreInstanceHost, CoreInstanceState}; @@ -66,16 +66,16 @@ where } } - #[cfg(feature = "dhcp-ipv4")] - async fn start_dhcp_ipv4(&self, host: Option>) -> anyhow::Result<()> { - if !self.runtime_config.snapshot().services.dhcp_ipv4 { + #[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] + async fn start_dhcp(&self, host: Option>) -> anyhow::Result<()> { + let config = self.runtime_config.snapshot(); + if !config.services.dhcp_ipv4 && !config.services.dhcp_ipv6 { return Ok(()); } - let host = host.ok_or_else(|| { - anyhow::anyhow!("DHCP IPv4 is enabled but no host adapter was provided") - })?; - let route_source: Arc = self.peer_manager.clone(); - self.dhcp_ipv4 + let host = host + .ok_or_else(|| anyhow::anyhow!("DHCP is enabled but no host adapter was provided"))?; + let route_source: Arc = self.peer_manager.clone(); + self.dhcp .start(route_source, self.runtime_config.clone(), host) .await; Ok(()) @@ -125,9 +125,9 @@ where self.public_ipv6_provider.start().await; let dhcp_host = self.instance_runtime.prepare(self.packet_plane()).await?; - #[cfg(feature = "dhcp-ipv4")] - self.start_dhcp_ipv4(dhcp_host).await?; - #[cfg(not(feature = "dhcp-ipv4"))] + #[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] + self.start_dhcp(dhcp_host).await?; + #[cfg(not(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6")))] let _ = dhcp_host; #[cfg(feature = "wrapped-transport")] if let Some(wrapped_transport) = &self.wrapped_transport { @@ -173,8 +173,8 @@ where self.vpn_portal.stop().await; #[cfg(feature = "public-ipv6-provider")] self.public_ipv6_provider.stop().await; - #[cfg(feature = "dhcp-ipv4")] - self.dhcp_ipv4.stop().await; + #[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] + self.dhcp.stop().await; #[cfg(feature = "proxy-cidr-monitor")] self.proxy_cidr_monitor.stop().await; if let Some(listener) = &self.listener { diff --git a/easytier-core/src/instance/mod.rs b/easytier-core/src/instance/mod.rs index 3ae2c028..d9df9d7d 100644 --- a/easytier-core/src/instance/mod.rs +++ b/easytier-core/src/instance/mod.rs @@ -58,7 +58,7 @@ use crate::{ }, }, events::CoreEventSink, - gateway::dhcp::DhcpIpv4Host, + gateway::dhcp::DhcpHost, host::{ dns::{DnsRecordResolver, DnsResolver}, packet::{HostPacketReceiver, PacketSink, host_packet_channel}, @@ -96,8 +96,8 @@ use crate::gateway::vpn_portal::PortalRuntimeConfig; #[cfg(feature = "public-ipv6-provider")] use crate::peers::public_ipv6::provider::PublicIpv6ProviderPlatform; -#[cfg(feature = "dhcp-ipv4")] -use crate::gateway::dhcp::DhcpIpv4Runtime; +#[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] +use crate::gateway::dhcp::DhcpRuntime; #[cfg(feature = "proxy-cidr-monitor")] use crate::gateway::proxy::cidr_monitor::ProxyCidrMonitorRuntime; #[cfg(feature = "proxy-packet")] @@ -238,7 +238,7 @@ pub trait InstanceRuntimeHost: std::any::Any + Send + Sync + 'static { async fn prepare( &self, packet_plane: Arc, - ) -> anyhow::Result>>; + ) -> anyhow::Result>>; async fn shutdown(&self); @@ -276,7 +276,7 @@ impl InstanceRuntimeHost for () { async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { Ok(None) } @@ -435,8 +435,8 @@ where packet_proxy: Arc>, #[cfg(feature = "proxy-cidr-monitor")] proxy_cidr_monitor: ProxyCidrMonitorRuntime, - #[cfg(feature = "dhcp-ipv4")] - dhcp_ipv4: DhcpIpv4Runtime, + #[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] + dhcp: DhcpRuntime, pub(super) packet_egress: Arc, pub(super) packet_receiver: Mutex>, pub(super) peer_center: Arc, @@ -884,8 +884,8 @@ where packet_proxy, #[cfg(feature = "proxy-cidr-monitor")] proxy_cidr_monitor, - #[cfg(feature = "dhcp-ipv4")] - dhcp_ipv4: DhcpIpv4Runtime::new(), + #[cfg(any(feature = "dhcp-ipv4", feature = "dhcp-ipv6"))] + dhcp: DhcpRuntime::new(), packet_egress, packet_receiver: Mutex::new(Some(packet_rx)), peer_center, diff --git a/easytier-core/src/instance/tests.rs b/easytier-core/src/instance/tests.rs index ff00a6ed..202f5285 100644 --- a/easytier-core/src/instance/tests.rs +++ b/easytier-core/src/instance/tests.rs @@ -628,6 +628,57 @@ mod portable_runtime { ); } + #[cfg(feature = "dhcp-ipv6")] + #[tokio::test] + async fn runtime_update_preserves_dhcp_owned_ipv6() { + let mut initial = test_config("dhcp-runtime-update"); + initial.connectivity.runtime.dhcp_ipv6 = true; + let instance = build_instance(initial).unwrap(); + let lease = IpPrefix { + address: "fd12::7".parse().unwrap(), + prefix_len: 64, + }; + instance.runtime_config.update_peer_with(|peer| { + peer.runtime.core.routes.ipv6 = Some(lease.clone()); + }); + + let mut replacement = test_config("dhcp-runtime-update"); + replacement.connectivity.runtime.dhcp_ipv6 = true; + instance + .update_runtime_config(runtime_snapshot(&replacement)) + .await + .unwrap(); + + assert_eq!( + instance + .runtime_config + .snapshot() + .peer + .runtime + .core + .routes + .ipv6, + Some(lease) + ); + + let static_replacement = test_config("dhcp-runtime-update"); + instance + .update_runtime_config(runtime_snapshot(&static_replacement)) + .await + .unwrap(); + assert_eq!( + instance + .runtime_config + .snapshot() + .peer + .runtime + .core + .routes + .ipv6, + None + ); + } + #[tokio::test] async fn core_instance_is_a_direct_managed_record() { let instance = build_instance(test_config("managed-directly")).unwrap(); @@ -1332,7 +1383,7 @@ virtual_ip = "10.82.0.2/24" async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { Ok(None) } @@ -1366,7 +1417,7 @@ virtual_ip = "10.82.0.2/24" async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { anyhow::bail!("host prepare failed") } @@ -1477,7 +1528,7 @@ virtual_ip = "10.82.0.2/24" async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { self.prepare_started.notify_one(); self.prepare_release.notified().await; Ok(None) @@ -1531,7 +1582,7 @@ virtual_ip = "10.82.0.2/24" async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { assert_eq!(tokio::runtime::Handle::current().id(), self.handle.id()); self.prepare_started.notify_one(); if self.block_prepare { @@ -1616,7 +1667,7 @@ virtual_ip = "10.82.0.2/24" async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { Ok(None) } @@ -1698,7 +1749,7 @@ virtual_ip = "10.82.0.2/24" async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { Ok(None) } @@ -1988,7 +2039,7 @@ virtual_ip = "10.82.0.2/24" async fn prepare( &self, _packet_plane: Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { Ok(None) } diff --git a/easytier-core/src/management/full/config_patch.rs b/easytier-core/src/management/full/config_patch.rs index f57f30a6..f5de2d50 100644 --- a/easytier-core/src/management/full/config_patch.rs +++ b/easytier-core/src/management/full/config_patch.rs @@ -91,7 +91,9 @@ where { candidate.set_ipv4(Some(ipv4.into())); } - if let Some(ipv6) = patch.ipv6 { + if let Some(ipv6) = patch.ipv6 + && !candidate.get_dhcpv6() + { candidate.set_ipv6(Some(ipv6.into())); } candidate.patch_flags(FlagsPatch { diff --git a/easytier-core/src/management/full/instance_info.rs b/easytier-core/src/management/full/instance_info.rs index 0c471f22..933405ec 100644 --- a/easytier-core/src/management/full/instance_info.rs +++ b/easytier-core/src/management/full/instance_info.rs @@ -60,6 +60,7 @@ where dev_name, my_node_info: Some(MyNodeInfo { virtual_ipv4: node.ipv4_addr.map(Into::into), + virtual_ipv6: node.ipv6_addr.map(Into::into), hostname: node.hostname, version: node.version, ips: Some(node.ip_list), diff --git a/easytier-core/src/peers/peer_manager.rs b/easytier-core/src/peers/peer_manager.rs index 14496f0c..558f34de 100644 --- a/easytier-core/src/peers/peer_manager.rs +++ b/easytier-core/src/peers/peer_manager.rs @@ -98,6 +98,7 @@ pub struct PeerSnapshot { pub struct NodeSnapshot { pub peer_id: PeerId, pub ipv4_addr: Option, + pub ipv6_addr: Option, pub proxy_networks: Vec, pub hostname: String, pub stun_info: StunInfo, @@ -283,6 +284,9 @@ fn retain_runtime_owned_peer_state( if current.services.dhcp_ipv4 && next.services.dhcp_ipv4 { next_peer.runtime.core.routes.ipv4 = current.peer.runtime.core.routes.ipv4.clone(); } + if current.services.dhcp_ipv6 && next.services.dhcp_ipv6 { + next_peer.runtime.core.routes.ipv6 = current.peer.runtime.core.routes.ipv6.clone(); + } } fn validate_portable_routes(routes: &crate::config::RouteConfig) -> anyhow::Result<()> { @@ -1265,6 +1269,7 @@ impl PeerManagerCore { NodeSnapshot { peer_id: self.my_peer_id, ipv4_addr: self.context.ipv4(), + ipv6_addr: self.context.ipv6(), proxy_networks: self.context.proxy_networks(), hostname: self.context.hostname(), stun_info: self.context.stun_info(), diff --git a/easytier-core/src/wasi/runtime.rs b/easytier-core/src/wasi/runtime.rs index 6adc3cfc..0750f84c 100644 --- a/easytier-core/src/wasi/runtime.rs +++ b/easytier-core/src/wasi/runtime.rs @@ -3,24 +3,20 @@ use crate::{ config::toml::TomlConfig, connectivity::connector_host::HostConnectorEnvironmentSnapshot, - gateway::dhcp::{DhcpIpv4ApplyOutcome, DhcpIpv4Host}, + gateway::dhcp::{DhcpAddresses, DhcpApplyOutcome, DhcpHost}, instance::{CorePacketPlane, InstanceRuntimeHost}, }; struct WasiInstanceRuntimeHost; #[async_trait::async_trait] -impl DhcpIpv4Host for WasiInstanceRuntimeHost { +impl DhcpHost for WasiInstanceRuntimeHost { fn take_interface_closed(&self) -> bool { false } - async fn apply_dhcp_ipv4( - &self, - _previous: Option, - next: Option, - ) -> DhcpIpv4ApplyOutcome { - DhcpIpv4ApplyOutcome::applied(next) + async fn apply_dhcp(&self, next: DhcpAddresses) -> DhcpApplyOutcome { + DhcpApplyOutcome::applied(next) } } @@ -29,7 +25,7 @@ impl InstanceRuntimeHost for WasiInstanceRuntimeHost { async fn prepare( &self, _packet_plane: std::sync::Arc, - ) -> anyhow::Result>> { + ) -> anyhow::Result>> { Ok(Some(std::sync::Arc::new(WasiInstanceRuntimeHost))) } diff --git a/easytier-core/src/wasi/web_client.rs b/easytier-core/src/wasi/web_client.rs index 415783f7..de03be6f 100644 --- a/easytier-core/src/wasi/web_client.rs +++ b/easytier-core/src/wasi/web_client.rs @@ -85,6 +85,8 @@ fn hosted_network_config(config: &NetworkConfig) -> NetworkConfig { instance_id: config.instance_id.clone(), dhcp: config.dhcp, dhcp_ipv4_cidr: config.dhcp_ipv4_cidr.clone(), + dhcpv6: config.dhcpv6, + dhcp_ipv6_cidr: config.dhcp_ipv6_cidr.clone(), virtual_ipv4: config.virtual_ipv4.clone(), network_length: config.network_length, hostname: config.hostname.clone(), @@ -405,6 +407,8 @@ mod tests { instance_id: Some(uuid::Uuid::new_v4().to_string()), dhcp: Some(true), dhcp_ipv4_cidr: Some("192.168.100.0/24".to_owned()), + dhcpv6: Some(true), + dhcp_ipv6_cidr: Some("fd12::/64".to_owned()), network_name: Some("network".to_owned()), network_secret: Some("secret".to_owned()), networking_method: Some(NetworkingMethod::Manual as i32), @@ -455,6 +459,8 @@ mod tests { let hosted = hosted_network_config(&original); assert_eq!(hosted.dhcp_ipv4_cidr, original.dhcp_ipv4_cidr); + assert_eq!(hosted.dhcpv6, original.dhcpv6); + assert_eq!(hosted.dhcp_ipv6_cidr, original.dhcp_ipv6_cidr); assert_eq!( hosted.peer_urls, vec!["tcp://peer.example:11010".to_owned()] diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index e344b7eb..b3a098e4 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -1090,7 +1090,9 @@ mod manager { loop { match event_receiver.recv().await { Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _) - | easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Conflicted(_)) => { + | easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Conflicted(_) + | easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv6Changed(_, _) + | easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv6Conflicted(_)) => { let _ = app_clone.emit("dhcp_ip_changed", &instance_id_str); } Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _) diff --git a/easytier-gui/src/composables/mobile_vpn.test.ts b/easytier-gui/src/composables/mobile_vpn.test.ts index 9ee8234d..9a05a9f4 100644 --- a/easytier-gui/src/composables/mobile_vpn.test.ts +++ b/easytier-gui/src/composables/mobile_vpn.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { IPv4CidrRange } from 'ip-num/IPRange' +import { IPv4CidrRange, IPv6CidrRange } from 'ip-num/IPRange' import type { MobileTunConfig } from './backend' const mocks = vi.hoisted(() => { @@ -72,17 +72,26 @@ function makeMobileTunConfig(instanceIds: string[]): MobileTunConfig { const config = mocks.configs.get(id)! const info = mocks.networkInfo.get(id) as any const ipv4 = info?.my_node_info?.virtual_ipv4 - if (!ipv4?.address?.addr || info.error_msg) + const ipv6 = info?.my_node_info?.virtual_ipv6 + if ((!ipv4?.address?.addr && !ipv6?.address?.addr) || info.error_msg) continue - const address = `${ipv4.address.addr}/${ipv4.network_length || 24}` - addresses.push(address) - const range = IPv4CidrRange.fromCidr(address) - routes.add(`${range.getFirst()}/${range.getPrefix()}`) + if (ipv4?.address?.addr) { + const address = `${ipv4.address.addr}/${ipv4.network_length || 24}` + addresses.push(address) + const range = IPv4CidrRange.fromCidr(address) + routes.add(`${range.getFirst()}/${range.getPrefix()}`) + } + if (ipv6?.address?.addr) { + const address = `${ipv6.address.addr}/${ipv6.network_length || 64}` + addresses.push(address) + const range = IPv6CidrRange.fromCidr(address) + routes.add(`${range.getFirst()}/${range.getPrefix()}`) + } const configuredRoutes = config.enable_manual_routes ? config.routes as string[] : (info.routes ?? []).flatMap((route: any) => route.proxy_cidrs ?? []) configuredRoutes.forEach((route: string) => routes.add(route.includes('/') ? route : `${route}/32`)) - if (config.enable_magic_dns) { + if (config.enable_magic_dns && ipv4?.address?.addr) { routes.add('100.100.100.101/32') dns = '100.100.100.101' } @@ -113,6 +122,12 @@ function setReady(instanceId: string, ipv4: string) { }) } +function setReadyIpv6(instanceId: string, ipv6: string) { + const info = mocks.networkInfo.get(instanceId) as any ?? { my_node_info: {}, routes: [] } + info.my_node_info.virtual_ipv6 = { address: { addr: ipv6 }, network_length: 64 } + mocks.networkInfo.set(instanceId, info) +} + async function loadVpnModule() { const mobileVpn = await import('./mobile_vpn') await mobileVpn.initMobileVpnService() @@ -147,6 +162,92 @@ beforeEach(() => { }) describe('mobile VPN reconciliation', () => { + it('starts an IPv6-only VPN after DHCPv6 becomes ready', async () => { + setConfig('A') + mocks.configs.get('A')!.dhcpv6 = true + mocks.configs.get('A')!.enable_magic_dns = true + mocks.networkInfo.set('A', { my_node_info: {}, routes: [] }) + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + expect(mocks.startVpn).not.toHaveBeenCalled() + + setReadyIpv6('A', 'fd12::7') + await vi.advanceTimersByTimeAsync(2000) + expect(mocks.startVpn).toHaveBeenCalledOnce() + expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ + ipv4Addrs: ['fd12::7/64'], + routes: ['fd12:0:0:0:0:0:0:0/64'], + dns: undefined, + })) + expect(mocks.setTunFd).toHaveBeenCalledWith(1, ['A']) + }) + + it('preserves IPv4 when the DHCPv6 address changes or is withdrawn', async () => { + setConfig('A') + mocks.configs.get('A')!.dhcpv6 = true + setReady('A', '10.0.0.1') + setReadyIpv6('A', 'fd12::7') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + + setReadyIpv6('A', 'fd13::8') + await vpn.onNetworkInstanceUpdate('A') + expect(mocks.stopVpn).toHaveBeenCalledOnce() + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24', 'fd13::8/64'], + routes: ['10.0.0.0/24', 'fd13:0:0:0:0:0:0:0/64'], + })) + + setReady('A', '10.0.0.1') + await vpn.onNetworkInstanceUpdate('A') + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24'], + routes: ['10.0.0.0/24'], + })) + }) + + it('keeps IPv6 active after IPv4 is withdrawn and stops after both are lost', async () => { + setConfig('A') + mocks.configs.get('A')!.dhcp = true + mocks.configs.get('A')!.dhcpv6 = true + setReady('A', '10.0.0.1') + setReadyIpv6('A', 'fd12::7') + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + + mocks.networkInfo.set('A', { my_node_info: {}, routes: [] }) + setReadyIpv6('A', 'fd12::7') + await vpn.onNetworkInstanceUpdate('A') + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['fd12::7/64'], + })) + expect(mocks.setTunFd).toHaveBeenLastCalledWith(1, ['A']) + + mocks.networkInfo.set('A', { my_node_info: {}, routes: [] }) + await vpn.onNetworkInstanceUpdate('A') + expect(mocks.setTunFd).toHaveBeenLastCalledWith(0) + }) + + it('adds a DHCPv6 member to the shared VPN without blocking a ready IPv4 member', async () => { + setConfig('A') + setConfig('B') + setReady('A', '10.0.0.1') + mocks.configs.get('B')!.dhcpv6 = true + mocks.networkInfo.set('B', { my_node_info: {}, routes: [] }) + const vpn = await loadVpnModule() + await vpn.onNetworkInstanceChange('A') + expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24'], + })) + + setReadyIpv6('B', 'fd12::7') + await vpn.onNetworkInstanceUpdate('B') + expect(mocks.startVpn).toHaveBeenLastCalledWith(expect.objectContaining({ + ipv4Addrs: ['10.0.0.1/24', 'fd12::7/64'], + })) + expect(mocks.setTunFd).toHaveBeenLastCalledWith(1, ['A', 'B']) + }) + it('preserves healthy members and reports a rejected bootstrap member', async () => { setConfig('A') setReady('A', '10.126.126.1') diff --git a/easytier-gui/src/composables/mobile_vpn.ts b/easytier-gui/src/composables/mobile_vpn.ts index 681caab8..593a2f1e 100644 --- a/easytier-gui/src/composables/mobile_vpn.ts +++ b/easytier-gui/src/composables/mobile_vpn.ts @@ -378,12 +378,13 @@ async function reconcileNetworkInstance(generation: number) { } const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4 - const virtualIp = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined - if (!virtualIp) { - if (!config.dhcp && !config.virtual_ipv4 && curNetworkInfo.my_node_info) + const virtualIpv6 = curNetworkInfo.my_node_info?.virtual_ipv6 + const dhcp = config.dhcp || config.dhcpv6 + if (!virtualIpv4?.address?.addr && !virtualIpv6?.address) { + if (!dhcp && !config.virtual_ipv4 && curNetworkInfo.my_node_info) continue - retryReason ??= config.dhcp ? 'dhcp_ipv4_unavailable' : 'static_ipv4_unavailable' - if (!config.dhcp) + retryReason ??= dhcp ? 'dhcp_address_unavailable' : 'static_address_unavailable' + if (!dhcp) networkInfoUnavailable = true continue } diff --git a/easytier-proto/proto/api_manage.proto b/easytier-proto/proto/api_manage.proto index ab559969..44c73db2 100644 --- a/easytier-proto/proto/api_manage.proto +++ b/easytier-proto/proto/api_manage.proto @@ -108,6 +108,8 @@ message NetworkConfig { optional bool prefer_peer_relay = 72; // Bootstrap subnet used when DHCP has not learned a subnet from peers. optional string dhcp_ipv4_cidr = 74; + optional bool dhcpv6 = 75; + optional string dhcp_ipv6_cidr = 76; } message ManagedCredentialConfig { @@ -160,6 +162,7 @@ message MyNodeInfo { repeated common.Url listeners = 6; optional string vpn_portal_cfg = 7 [deprecated = true]; uint32 peer_id = 8; + common.Ipv6Inet virtual_ipv6 = 9; } message NetworkInstanceRunningInfo { diff --git a/easytier-web/frontend-lib/scripts/test-network-config.mjs b/easytier-web/frontend-lib/scripts/test-network-config.mjs index 89841c19..08223c22 100644 --- a/easytier-web/frontend-lib/scripts/test-network-config.mjs +++ b/easytier-web/frontend-lib/scripts/test-network-config.mjs @@ -26,6 +26,7 @@ const { const BOOLEAN_CONFIG_FIELDS = [ 'dhcp', + 'dhcpv6', 'advanced_settings', 'latency_first', 'use_smoltcp', @@ -113,6 +114,9 @@ function allFieldFixture() { ...DEFAULT_NETWORK_CONFIG(), instance_id: '11111111-2222-3333-4444-555555555555', dhcp: false, + dhcp_ipv4_cidr: '10.9.8.0/25', + dhcpv6: true, + dhcp_ipv6_cidr: 'fd12::/64', virtual_ipv4: '10.9.8.7', network_length: 25, hostname: 'frontend-e2e', diff --git a/easytier-web/frontend-lib/src/components/HumanEvent.vue b/easytier-web/frontend-lib/src/components/HumanEvent.vue index b5ddc48b..bfe28d64 100644 --- a/easytier-web/frontend-lib/src/components/HumanEvent.vue +++ b/easytier-web/frontend-lib/src/components/HumanEvent.vue @@ -25,7 +25,7 @@ const eventValue = computed(() => {