From 03078b47918dce8c51cb184dbd76226992f6ce39 Mon Sep 17 00:00:00 2001 From: KKRainbow <5665404+KKRainbow@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:36:47 +0800 Subject: [PATCH] feat(dhcp): support automatic IPv6 address assignment (#2688) Accept a boolean or an IPv6 CIDR in dhcpv6 and --dhcpv6, using fd6b:8c2d:91e4::/64 when no initial subnet is supplied. Keep IPv4 configuration serialization compatible and DHCPv6 disabled by default. Share the DHCP lifecycle and host update between both address families, with typed allocation state, peer subnet selection, conflict recovery, and bounded IPv6 probing. Preserve static addresses of the other family and prevent static config patches from replacing DHCP-owned leases. Carry IPv6 settings, leases, and events through management, web config, and mobile VPN setup. Reuse the shared NIC registry and mobile address list for IPv6-only and dual-stack operation without extra FD owners. Cover config round trips, allocation and recovery, mobile reconciliation, three-node IPv6 and dual-stack connectivity, and shared TUN member exit. Validate with workspace clippy, Docker TUN tests, frontend tests/builds, WASI and Android Rust checks, and Android VPN Kotlin compilation. --- easytier-core/Cargo.toml | 2 + easytier-core/src/config/api.rs | 5 + easytier-core/src/config/api_input.rs | 44 + easytier-core/src/config/dhcp.rs | 58 +- easytier-core/src/config/mod.rs | 1 + easytier-core/src/config/runtime.rs | 8 +- easytier-core/src/config/toml.rs | 95 +- easytier-core/src/gateway/dhcp.rs | 913 +++++++++++++----- .../src/instance/build_capabilities.rs | 2 + easytier-core/src/instance/config.rs | 42 +- easytier-core/src/instance/lifecycle.rs | 30 +- easytier-core/src/instance/mod.rs | 18 +- easytier-core/src/instance/tests.rs | 65 +- .../src/management/full/config_patch.rs | 4 +- .../src/management/full/instance_info.rs | 1 + easytier-core/src/peers/peer_manager.rs | 5 + easytier-core/src/wasi/runtime.rs | 14 +- easytier-core/src/wasi/web_client.rs | 6 + easytier-gui/src-tauri/src/lib.rs | 4 +- .../src/composables/mobile_vpn.test.ts | 115 ++- easytier-gui/src/composables/mobile_vpn.ts | 11 +- easytier-proto/proto/api_manage.proto | 3 + .../scripts/test-network-config.mjs | 4 + .../src/components/HumanEvent.vue | 2 +- easytier-web/frontend-lib/src/locales/cn.yaml | 2 + easytier-web/frontend-lib/src/locales/en.yaml | 2 + .../frontend-lib/src/types/network.ts | 4 + easytier-web/src/central_network/compiler.rs | 6 + easytier/Cargo.toml | 2 + easytier/locales/app.yml | 3 + easytier/src/common/global_ctx.rs | 33 +- easytier/src/core.rs | 49 + easytier/src/instance/cli_event_logger.rs | 6 + easytier/src/instance/runtime_host.rs | 7 +- easytier/src/instance/runtime_host/dhcp.rs | 89 ++ .../instance/runtime_host/implementation.rs | 4 +- .../src/instance/runtime_host/tun_common.rs | 179 ++-- .../src/instance/runtime_host/tun_desktop.rs | 13 +- .../src/instance/runtime_host/tun_disabled.rs | 70 +- .../src/instance/runtime_host/tun_mobile.rs | 9 +- easytier/src/tests/shared_virtual_nic.rs | 90 +- easytier/src/tests/three_node.rs | 131 +++ .../android/src/main/java/TauriVpnService.kt | 10 +- 43 files changed, 1639 insertions(+), 522 deletions(-) create mode 100644 easytier/src/instance/runtime_host/dhcp.rs 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(() => {