diff --git a/easytier/src/common/global_ctx.rs b/easytier/src/common/global_ctx.rs index 91a4c457..9073e201 100644 --- a/easytier/src/common/global_ctx.rs +++ b/easytier/src/common/global_ctx.rs @@ -219,6 +219,7 @@ pub struct GlobalCtx { running_listeners: Mutex>, advertised_ipv6_public_addr_prefix: Mutex>, + tun_device_name: Mutex>, flags: ArcSwap, @@ -336,6 +337,7 @@ impl GlobalCtx { running_listeners: Mutex::new(Vec::new()), advertised_ipv6_public_addr_prefix: Mutex::new(None), + tun_device_name: Mutex::new(None), flags: ArcSwap::new(Arc::new(flags)), @@ -370,6 +372,24 @@ impl GlobalCtx { } } + fn set_tun_device_name(&self, name: Option) { + *self.tun_device_name.lock().unwrap() = name; + } + + pub(crate) fn set_tun_device_ready(&self, name: String) { + self.set_tun_device_name(Some(name.clone())); + self.issue_event(GlobalCtxEvent::TunDeviceReady(name)); + } + + pub(crate) fn set_tun_device_error(&self, error: String) { + self.set_tun_device_name(None); + self.issue_event(GlobalCtxEvent::TunDeviceError(error)); + } + + pub fn get_tun_device_name(&self) -> Option { + self.tun_device_name.lock().unwrap().clone() + } + pub fn check_network_in_whitelist(&self, network_name: &str) -> Result<(), anyhow::Error> { if self .get_flags() @@ -825,6 +845,36 @@ pub mod tests { ); } + #[tokio::test] + async fn test_tun_device_name_tracks_explicit_runtime_state() { + let config = TomlConfigLoader::default(); + let global_ctx = GlobalCtx::new(config); + + assert_eq!(global_ctx.get_tun_device_name(), None); + + global_ctx.issue_event(GlobalCtxEvent::TunDeviceReady("ignored".to_string())); + assert_eq!(global_ctx.get_tun_device_name(), None); + + let mut subscriber = global_ctx.subscribe(); + + global_ctx.set_tun_device_ready("easytier0".to_string()); + assert_eq!( + global_ctx.get_tun_device_name(), + Some("easytier0".to_string()) + ); + assert_eq!( + subscriber.recv().await.unwrap(), + GlobalCtxEvent::TunDeviceReady("easytier0".to_string()) + ); + + global_ctx.set_tun_device_error("closed".to_string()); + assert_eq!(global_ctx.get_tun_device_name(), None); + assert_eq!( + subscriber.recv().await.unwrap(), + GlobalCtxEvent::TunDeviceError("closed".to_string()) + ); + } + #[tokio::test] async fn trusted_key_source_lookup_is_precise() { let config = TomlConfigLoader::default(); diff --git a/easytier/src/common/ifcfg/mod.rs b/easytier/src/common/ifcfg/mod.rs index 3c2b20b5..6139e733 100644 --- a/easytier/src/common/ifcfg/mod.rs +++ b/easytier/src/common/ifcfg/mod.rs @@ -177,3 +177,20 @@ pub(crate) fn list_ipv6_route_messages() pub(crate) fn get_interface_index(name: &str) -> Result { netlink::NetlinkIfConfiger::get_interface_index(name) } + +#[cfg(target_os = "linux")] +pub(crate) fn add_ipv6_ndp_proxy(name: &str, address: Ipv6Addr) -> Result<(), Error> { + netlink::NetlinkIfConfiger::add_ipv6_ndp_proxy(name, address) +} + +#[cfg(target_os = "linux")] +pub(crate) fn remove_ipv6_ndp_proxy(name: &str, address: Ipv6Addr) -> Result<(), Error> { + netlink::NetlinkIfConfiger::remove_ipv6_ndp_proxy(name, address) +} + +#[cfg(target_os = "linux")] +pub(crate) fn list_ipv6_ndp_proxy( + name: &str, +) -> Result, Error> { + netlink::NetlinkIfConfiger::list_ipv6_ndp_proxy(name) +} diff --git a/easytier/src/common/ifcfg/netlink.rs b/easytier/src/common/ifcfg/netlink.rs index b620c215..63cdd916 100644 --- a/easytier/src/common/ifcfg/netlink.rs +++ b/easytier/src/common/ifcfg/netlink.rs @@ -1,4 +1,5 @@ use std::{ + collections::BTreeSet, ffi::CString, fmt::Debug, net::{IpAddr, Ipv4Addr, Ipv6Addr}, @@ -16,6 +17,10 @@ use netlink_packet_core::{ use netlink_packet_route::{ AddressFamily, RouteNetlinkMessage, address::{AddressAttribute, AddressMessage}, + neighbour::{ + NeighbourAddress, NeighbourAttribute, NeighbourFlags, NeighbourHeader, NeighbourMessage, + NeighbourState, + }, route::{ RouteAddress, RouteAttribute, RouteHeader, RouteMessage, RouteProtocol, RouteScope, RouteType, @@ -375,6 +380,105 @@ impl NetlinkIfConfiger { pub(crate) fn list_ipv6_route_messages() -> Result, Error> { Self::list_route_messages(AddressFamily::Inet6) } + + fn ipv6_ndp_proxy_message(name: &str, address: Ipv6Addr) -> Result { + let mut message = NeighbourMessage::default(); + message.header = NeighbourHeader { + family: AddressFamily::Inet6, + ifindex: Self::get_interface_index(name)?, + state: NeighbourState::Permanent, + flags: NeighbourFlags::Proxy, + kind: RouteType::Unicast, + }; + message + .attributes + .push(NeighbourAttribute::Destination(NeighbourAddress::Inet6( + address, + ))); + Ok(message) + } + + pub(crate) fn add_ipv6_ndp_proxy(name: &str, address: Ipv6Addr) -> Result<(), Error> { + send_netlink_req_and_wait_one_resp( + RouteNetlinkMessage::NewNeighbour(Self::ipv6_ndp_proxy_message(name, address)?), + false, + ) + } + + pub(crate) fn remove_ipv6_ndp_proxy(name: &str, address: Ipv6Addr) -> Result<(), Error> { + send_netlink_req_and_wait_one_resp( + RouteNetlinkMessage::DelNeighbour(Self::ipv6_ndp_proxy_message(name, address)?), + true, + ) + } + + fn list_neighbour_messages( + address_family: AddressFamily, + ) -> Result, Error> { + let mut message = NeighbourMessage::default(); + message.header.family = address_family; + message.header.flags = NeighbourFlags::Proxy; + + let s = send_netlink_req( + RouteNetlinkMessage::GetNeighbour(message), + NLM_F_REQUEST | NLM_F_DUMP, + )?; + + let mut ret_vec = vec![]; + let mut resp = Vec::::new(); + loop { + if resp.is_empty() { + let (new_resp, _) = s.recv_from_full()?; + resp = new_resp; + } + + let ret = NetlinkMessage::::deserialize(&resp) + .with_context(|| "Failed to deserialize netlink neighbour message")?; + resp = resp.split_off(ret.buffer_len()); + + tracing::debug!("net link response <<< {:?}", ret); + + match ret.payload { + NetlinkPayload::Error(e) => { + if e.code == NonZero::new(0) { + continue; + } else { + return Err(e.to_io().into()); + } + } + NetlinkPayload::InnerMessage(RouteNetlinkMessage::NewNeighbour(m)) => { + ret_vec.push(m); + } + NetlinkPayload::Done(_) => { + break; + } + p => { + tracing::error!("Unexpected netlink response: {:?}", p); + return Err(anyhow::anyhow!("Unexpected netlink response").into()); + } + } + } + + Ok(ret_vec) + } + + pub(crate) fn list_ipv6_ndp_proxy(name: &str) -> Result, Error> { + let ifindex = Self::get_interface_index(name)?; + + Ok(Self::list_neighbour_messages(AddressFamily::Inet6)? + .into_iter() + .filter(|message| { + message.header.ifindex == ifindex + && message.header.flags.contains(NeighbourFlags::Proxy) + }) + .filter_map(|message| { + message.attributes.into_iter().find_map(|attr| match attr { + NeighbourAttribute::Destination(NeighbourAddress::Inet6(addr)) => Some(addr), + _ => None, + }) + }) + .collect()) + } } #[async_trait] diff --git a/easytier/src/instance/instance.rs b/easytier/src/instance/instance.rs index 22e22018..6a8ee3a6 100644 --- a/easytier/src/instance/instance.rs +++ b/easytier/src/instance/instance.rs @@ -65,9 +65,9 @@ use crate::vpn_portal::{self, VpnPortal}; use super::dns_server::{MAGIC_DNS_FAKE_IP, runner::DnsRunner}; use super::listeners::ListenerManager; use super::public_ipv6_provider::{ - reconcile_public_ipv6_provider_runtime, run_public_ipv6_provider_reconcile_task, - should_run_public_ipv6_provider_reconcile, validate_public_ipv6_config, - validate_public_ipv6_config_values, + PublicIpv6ProviderReconcileTask, reconcile_public_ipv6_provider_runtime, + run_public_ipv6_provider_reconcile_task, should_run_public_ipv6_provider_reconcile, + validate_public_ipv6_config, validate_public_ipv6_config_values, }; #[cfg(feature = "socks5")] @@ -194,6 +194,44 @@ impl NicCtxContainer { #[cfg(feature = "tun")] type ArcNicCtx = Arc>>; +type ArcPublicIpv6ProviderTaskSlot = Arc; + +struct PublicIpv6ProviderTaskSlot { + task: Mutex>, + closing: AtomicBool, +} + +impl PublicIpv6ProviderTaskSlot { + fn new() -> Self { + Self { + task: Mutex::new(None), + closing: AtomicBool::new(false), + } + } + + async fn ensure_started(&self, global_ctx: &ArcGlobalCtx) { + let mut task = self.task.lock().await; + if self.closing.load(Ordering::Acquire) || task.is_some() { + return; + } + *task = run_public_ipv6_provider_reconcile_task(global_ctx); + } + + async fn shutdown(&self) { + self.closing.store(true, Ordering::Release); + let task = self.task.lock().await.take(); + if let Some(task) = task { + task.shutdown().await; + } + } +} + +async fn ensure_public_ipv6_provider_reconcile_task( + global_ctx: &ArcGlobalCtx, + task_slot: &ArcPublicIpv6ProviderTaskSlot, +) { + task_slot.ensure_started(global_ctx).await; +} pub struct InstanceRpcServerHook { rpc_portal_whitelist: Vec, @@ -254,6 +292,7 @@ pub struct InstanceConfigPatcher { socks5_server: Weak, peer_manager: Weak, conn_manager: Weak, + public_ipv6_provider_task: ArcPublicIpv6ProviderTaskSlot, } impl InstanceConfigPatcher { @@ -324,7 +363,6 @@ impl InstanceConfigPatcher { self.patch_mapped_listeners(patch.mapped_listeners).await?; self.patch_connector(patch.connectors).await?; - let provider_reconcile_was_running = should_run_public_ipv6_provider_reconcile(&global_ctx); let mut provider_config_changed = false; if let Some(hostname) = patch.hostname { global_ctx.set_hostname(hostname.clone()); @@ -362,10 +400,12 @@ impl InstanceConfigPatcher { if provider_config_changed { reconcile_public_ipv6_provider_runtime(&global_ctx).await; - let provider_reconcile_should_run = - should_run_public_ipv6_provider_reconcile(&global_ctx); - if !provider_reconcile_was_running && provider_reconcile_should_run { - run_public_ipv6_provider_reconcile_task(&global_ctx); + if should_run_public_ipv6_provider_reconcile(&global_ctx) { + ensure_public_ipv6_provider_reconcile_task( + &global_ctx, + &self.public_ipv6_provider_task, + ) + .await; } } @@ -647,6 +687,7 @@ pub struct Instance { socks5_server: Arc, proxy_cidrs_monitor: Option>, + public_ipv6_provider_task: ArcPublicIpv6ProviderTaskSlot, global_ctx: ArcGlobalCtx, } @@ -734,6 +775,7 @@ impl Instance { socks5_server, proxy_cidrs_monitor: None, + public_ipv6_provider_task: Arc::new(PublicIpv6ProviderTaskSlot::new()), global_ctx, } @@ -1034,7 +1076,11 @@ impl Instance { .await?; self.listener_manager.lock().await.run().await?; self.peer_manager.run().await?; - run_public_ipv6_provider_reconcile_task(&self.global_ctx); + ensure_public_ipv6_provider_reconcile_task( + &self.global_ctx, + &self.public_ipv6_provider_task, + ) + .await; #[cfg(feature = "tun")] { @@ -1347,6 +1393,7 @@ impl Instance { socks5_server: Arc::downgrade(&self.socks5_server), peer_manager: Arc::downgrade(&self.peer_manager), conn_manager: Arc::downgrade(&self.conn_manager), + public_ipv6_provider_task: self.public_ipv6_provider_task.clone(), } } @@ -1602,6 +1649,7 @@ impl Instance { } pub async fn clear_resources(&mut self) { + self.public_ipv6_provider_task.shutdown().await; self.peer_manager.clear_resources().await; #[cfg(feature = "tun")] let _ = self.nic_ctx.lock().await.take(); @@ -1787,6 +1835,21 @@ mod tests { ); } + #[tokio::test] + async fn public_ipv6_provider_task_slot_does_not_restart_after_shutdown() { + let global_ctx = get_mock_global_ctx(); + let slot = std::sync::Arc::new(super::PublicIpv6ProviderTaskSlot::new()); + global_ctx.config.set_ipv6_public_addr_provider(true); + global_ctx + .config + .set_ipv6_public_addr_prefix(Some("2001:db8::/48".parse().unwrap())); + + slot.shutdown().await; + super::ensure_public_ipv6_provider_reconcile_task(&global_ctx, &slot).await; + + assert!(slot.task.lock().await.is_none()); + } + #[tokio::test] async fn validate_public_ipv6_patch_allows_enabling_auto_with_manual_ipv6() { let global_ctx = get_mock_global_ctx(); diff --git a/easytier/src/instance/public_ipv6_provider.rs b/easytier/src/instance/public_ipv6_provider.rs index e27f75c6..1be72a02 100644 --- a/easytier/src/instance/public_ipv6_provider.rs +++ b/easytier/src/instance/public_ipv6_provider.rs @@ -7,23 +7,41 @@ use anyhow::Context; use cidr::{Ipv6Cidr, Ipv6Inet}; #[cfg(target_os = "linux")] use netlink_packet_route::route::{RouteAddress, RouteAttribute, RouteMessage, RouteType}; +use tokio_util::sync::CancellationToken; #[cfg(target_os = "linux")] -use crate::common::ifcfg::{get_interface_index, list_ipv6_route_messages}; +use crate::common::ifcfg::{ + add_ipv6_ndp_proxy, get_interface_index, list_ipv6_ndp_proxy, list_ipv6_route_messages, + remove_ipv6_ndp_proxy, +}; use crate::common::{ error::Error, global_ctx::{ArcGlobalCtx, GlobalCtxEvent}, + netns::NetNS, }; const PUBLIC_IPV6_PROVIDER_RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_secs(5); const PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES: usize = 3; +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, PartialEq, Eq)] +struct NdpProxyTarget { + wan_iface: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PublicIpv6ProviderActiveState { + prefix: Ipv6Cidr, + #[cfg(target_os = "linux")] + ndp_proxy: Option, +} + #[derive(Debug, Clone, PartialEq, Eq)] enum PublicIpv6ProviderRuntimeState { Disabled, Pending(String), - Active(Ipv6Cidr), + Active(PublicIpv6ProviderActiveState), } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -44,7 +62,7 @@ fn read_public_ipv6_provider_config_snapshot( fn should_run_public_ipv6_provider_reconcile_task( config: PublicIpv6ProviderConfigSnapshot, ) -> bool { - config.provider_enabled && config.configured_prefix.is_none() + config.provider_enabled } pub(super) fn should_run_public_ipv6_provider_reconcile(global_ctx: &ArcGlobalCtx) -> bool { @@ -186,6 +204,13 @@ struct DetectedIpv6Route { kind: RouteType, } +#[cfg(target_os = "linux")] +#[derive(Clone, Debug, PartialEq, Eq)] +struct DetectedPublicIpv6Prefix { + prefix: Ipv6Cidr, + ndp_proxy: Option, +} + #[cfg(target_os = "linux")] fn ipv6_cidr_from_route_addr(addr: RouteAddress, prefix_len: u8) -> Option { match addr { @@ -234,11 +259,11 @@ fn is_ipv6_default_route(dst: Option) -> bool { fn detect_public_ipv6_prefix_from_routes( routes: &[DetectedIpv6Route], loopback_ifindex: u32, -) -> Option { +) -> Option { routes .iter() .filter_map(|route| { - if !is_ipv6_default_route(route.dst) { + if !is_ipv6_default_route(route.dst) || route.kind != RouteType::Unicast { return None; } @@ -256,26 +281,210 @@ fn detect_public_ipv6_prefix_from_routes( && candidate.kind == RouteType::Unicast }); - delegated.then_some(prefix) + delegated.then_some(DetectedPublicIpv6Prefix { + prefix, + ndp_proxy: None, + }) }) - .min_by_key(|prefix| prefix.network_length()) + .min_by_key(|detected| detected.prefix.network_length()) } #[cfg(target_os = "linux")] -async fn detect_public_ipv6_prefix_linux() -> Result, Error> { +#[derive(Clone, Debug, PartialEq, Eq)] +struct DetectedDefaultRouteIpv6Interface { + interface_name: String, + ifindex: u32, + address: std::net::Ipv6Addr, + prefix: Ipv6Cidr, +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Debug, PartialEq, Eq)] +struct DefaultRouteIpv6InterfaceCandidate { + interface_name: String, + ifindex: u32, + address: std::net::Ipv6Addr, + prefix_len: u8, +} + +#[cfg(target_os = "linux")] +fn default_route_ifindices(routes: &[DetectedIpv6Route]) -> std::collections::BTreeSet { + routes + .iter() + .filter(|route| is_ipv6_default_route(route.dst) && route.kind == RouteType::Unicast) + .filter_map(|route| route.ifindex) + .collect() +} + +#[cfg(target_os = "linux")] +fn select_default_route_ipv6_interfaces( + candidates: impl IntoIterator, + wan_ifindices: &std::collections::BTreeSet, + max_prefix_len: u8, +) -> Vec { + candidates + .into_iter() + .filter_map(|candidate| { + if !wan_ifindices.contains(&candidate.ifindex) { + return None; + } + + if candidate.address.is_loopback() + || candidate.address.is_multicast() + || candidate.address.is_unicast_link_local() + || candidate.address.is_unique_local() + || candidate.address.is_unspecified() + { + return None; + } + + if candidate.prefix_len == 0 || candidate.prefix_len > max_prefix_len { + return None; + } + + let prefix = Ipv6Inet::new(candidate.address, candidate.prefix_len) + .ok() + .map(|inet| inet.network())?; + + Some(DetectedDefaultRouteIpv6Interface { + interface_name: candidate.interface_name, + ifindex: candidate.ifindex, + address: candidate.address, + prefix, + }) + }) + .collect() +} + +#[cfg(target_os = "linux")] +fn detect_default_route_ipv6_interfaces( + routes: &[DetectedIpv6Route], + max_prefix_len: u8, +) -> Vec { + use nix::ifaddrs::getifaddrs; + use nix::sys::socket::SockaddrLike; + use pnet::ipnetwork::ip_mask_to_prefix; + + let wan_ifindices = default_route_ifindices(routes); + if wan_ifindices.is_empty() { + return Vec::new(); + } + + let Ok(interfaces) = getifaddrs() else { + return Vec::new(); + }; + + let candidates = interfaces + .filter_map(|iface| { + let address = iface.address?; + let netmask = iface.netmask?; + let ifindex = get_interface_index(&iface.interface_name).ok()?; + + if address.family()? != nix::sys::socket::AddressFamily::Inet6 { + return None; + } + + let ipv6_addr = address.as_sockaddr_in6()?.ip(); + let netmask_ip = netmask.as_sockaddr_in6()?.ip(); + let prefix_len = ip_mask_to_prefix(std::net::IpAddr::V6(netmask_ip)).ok()?; + + Some(DefaultRouteIpv6InterfaceCandidate { + interface_name: iface.interface_name, + ifindex, + address: ipv6_addr, + prefix_len, + }) + }) + .collect::>(); + + select_default_route_ipv6_interfaces(candidates, &wan_ifindices, max_prefix_len) +} + +#[cfg(target_os = "linux")] +fn select_public_ipv6_prefix_from_default_route_interfaces( + candidates: impl IntoIterator, +) -> Option { + let iface = candidates + .into_iter() + .min_by_key(|iface| (iface.prefix.network_length(), iface.ifindex))?; + Some(DetectedPublicIpv6Prefix { + prefix: iface.prefix, + ndp_proxy: Some(NdpProxyTarget { + wan_iface: iface.interface_name, + }), + }) +} + +#[cfg(target_os = "linux")] +fn detect_public_ipv6_prefix_from_interfaces( + routes: &[DetectedIpv6Route], +) -> Option { + select_public_ipv6_prefix_from_default_route_interfaces(detect_default_route_ipv6_interfaces( + routes, 64, + )) +} + +#[cfg(target_os = "linux")] +fn ipv6_cidr_contains_cidr(outer: Ipv6Cidr, inner: Ipv6Cidr) -> bool { + outer.contains(&inner.first_address()) && outer.contains(&inner.last_address()) +} + +#[cfg(target_os = "linux")] +fn detect_configured_prefix_ndp_proxy_target( + routes: &[DetectedIpv6Route], + prefix: Ipv6Cidr, +) -> Option { + let wan_ifindices = default_route_ifindices(routes); + if wan_ifindices.is_empty() { + return None; + } + + let loopback_ifindex = get_interface_index("lo").ok(); + let routed = routes.iter().any(|route| { + route.dst == Some(prefix) + && route.kind == RouteType::Unicast + && route.ifindex.is_some_and(|ifindex| { + !wan_ifindices.contains(&ifindex) && Some(ifindex) != loopback_ifindex + }) + }); + if routed { + return None; + } + + detect_default_route_ipv6_interfaces(routes, 128) + .into_iter() + .filter(|iface| { + ipv6_cidr_contains_cidr(iface.prefix, prefix) + || (iface.prefix.network_length() == 128 && prefix.contains(&iface.address)) + }) + .min_by_key(|iface| (iface.prefix.network_length(), iface.ifindex)) + .map(|iface| NdpProxyTarget { + wan_iface: iface.interface_name, + }) +} + +#[cfg(target_os = "linux")] +fn list_detected_ipv6_routes() -> Result, Error> { let routes = list_ipv6_route_messages().with_context(|| "failed to query linux ipv6 routes")?; - let routes = routes + routes .iter() .cloned() .map(DetectedIpv6Route::try_from) - .collect::, _>>()?; + .collect::, _>>() +} + +#[cfg(target_os = "linux")] +async fn detect_public_ipv6_prefix_linux() -> Result, Error> { + let routes = list_detected_ipv6_routes()?; let loopback_ifindex = get_interface_index("lo").with_context(|| "failed to resolve linux loopback ifindex")?; - Ok(detect_public_ipv6_prefix_from_routes( - &routes, - loopback_ifindex, - )) + if let Some(prefix) = detect_public_ipv6_prefix_from_routes(&routes, loopback_ifindex) { + return Ok(Some(prefix)); + } + + // Fallback for DHCPv6 IA_NA / SLAAC — see https://github.com/EasyTier/EasyTier/issues/2333 + Ok(detect_public_ipv6_prefix_from_interfaces(&routes)) } #[cfg(not(target_os = "linux"))] @@ -293,6 +502,19 @@ fn invalid_public_ipv6_prefix_state( )) } +#[cfg(target_os = "linux")] +fn active_public_ipv6_provider_state( + prefix: Ipv6Cidr, + ndp_proxy: Option, +) -> PublicIpv6ProviderRuntimeState { + PublicIpv6ProviderRuntimeState::Active(PublicIpv6ProviderActiveState { prefix, ndp_proxy }) +} + +#[cfg(not(target_os = "linux"))] +fn active_public_ipv6_provider_state(prefix: Ipv6Cidr) -> PublicIpv6ProviderRuntimeState { + PublicIpv6ProviderRuntimeState::Active(PublicIpv6ProviderActiveState { prefix }) +} + #[cfg(target_os = "linux")] async fn resolve_public_ipv6_provider_runtime_state_linux( global_ctx: &ArcGlobalCtx, @@ -308,14 +530,25 @@ async fn resolve_public_ipv6_provider_runtime_state_linux( if !is_global_routable_public_ipv6_prefix(prefix) { return invalid_public_ipv6_prefix_state(prefix, "configured"); } - return PublicIpv6ProviderRuntimeState::Active(prefix); + let ndp_proxy = match list_detected_ipv6_routes() { + Ok(routes) => detect_configured_prefix_ndp_proxy_target(&routes, prefix), + Err(err) => { + tracing::warn!( + prefix = %prefix, + ?err, + "failed to detect NDP proxy target for configured public IPv6 prefix" + ); + None + } + }; + return active_public_ipv6_provider_state(prefix, ndp_proxy); } match detect_public_ipv6_prefix_linux().await { - Ok(Some(prefix)) if is_global_routable_public_ipv6_prefix(prefix) => { - PublicIpv6ProviderRuntimeState::Active(prefix) + Ok(Some(detected)) if is_global_routable_public_ipv6_prefix(detected.prefix) => { + active_public_ipv6_provider_state(detected.prefix, detected.ndp_proxy) } - Ok(Some(prefix)) => invalid_public_ipv6_prefix_state(prefix, "detected"), + Ok(Some(detected)) => invalid_public_ipv6_prefix_state(detected.prefix, "detected"), Ok(None) => PublicIpv6ProviderRuntimeState::Pending( public_ipv6_provider_auto_detect_error().to_string(), ), @@ -356,7 +589,7 @@ fn apply_public_ipv6_provider_runtime_state( state: &PublicIpv6ProviderRuntimeState, ) -> bool { let next_prefix = match state { - PublicIpv6ProviderRuntimeState::Active(prefix) => Some(*prefix), + PublicIpv6ProviderRuntimeState::Active(active) => Some(active.prefix), PublicIpv6ProviderRuntimeState::Disabled | PublicIpv6ProviderRuntimeState::Pending(_) => { None } @@ -387,7 +620,10 @@ fn current_public_ipv6_provider_runtime_state( global_ctx.get_advertised_ipv6_public_addr_prefix(), ) { (false, _) => PublicIpv6ProviderRuntimeState::Disabled, - (true, Some(prefix)) => PublicIpv6ProviderRuntimeState::Active(prefix), + #[cfg(target_os = "linux")] + (true, Some(prefix)) => active_public_ipv6_provider_state(prefix, None), + #[cfg(not(target_os = "linux"))] + (true, Some(prefix)) => active_public_ipv6_provider_state(prefix), (true, None) => PublicIpv6ProviderRuntimeState::Pending( "public IPv6 provider runtime is missing an advertised prefix".to_string(), ), @@ -430,74 +666,446 @@ pub(super) async fn reconcile_public_ipv6_provider_runtime(global_ctx: &ArcGloba .1 } -pub(super) fn run_public_ipv6_provider_reconcile_task(global_ctx: &ArcGlobalCtx) { +#[cfg(target_os = "linux")] +#[derive(Default)] +struct NdpProxyRuntime { + wan_iface: Option, + applied: std::collections::BTreeSet, +} + +#[cfg(target_os = "linux")] +impl NdpProxyRuntime { + fn reconcile( + &mut self, + global_ctx: &ArcGlobalCtx, + state: &PublicIpv6ProviderRuntimeState, + ) -> bool { + let Some((prefix, target)) = ndp_proxy_target(state) else { + return !self.clear_current(global_ctx); + }; + + let Some(tun_iface) = global_ctx.get_tun_device_name() else { + self.clear_current(global_ctx); + tracing::debug!("waiting for tun device before syncing NDP proxy entries"); + return self.cleanup_pending(); + }; + + let _g = global_ctx.net_ns.guard(); + + if self.wan_iface.as_deref() != Some(target.wan_iface.as_str()) { + if !self.clear_current_locked() { + tracing::warn!( + old_wan_iface = ?self.wan_iface, + new_wan_iface = %target.wan_iface, + remaining_entries = self.applied.len(), + "waiting to remove old NDP proxy entries before switching WAN interface" + ); + return true; + } + self.wan_iface = Some(target.wan_iface.clone()); + } + + if let Err(err) = sync_ndp_proxy_entries( + target.wan_iface.as_str(), + tun_iface.as_str(), + prefix, + &mut self.applied, + ) { + tracing::warn!( + wan_iface = %target.wan_iface, + tun_iface = %tun_iface, + ?err, + "failed to sync NDP proxy entries" + ); + } + self.cleanup_pending() + } + + fn clear_current(&mut self, global_ctx: &ArcGlobalCtx) -> bool { + self.clear_current_in_netns(&global_ctx.net_ns) + } + + fn clear_current_in_netns(&mut self, net_ns: &NetNS) -> bool { + let _g = net_ns.guard(); + self.clear_current_locked() + } + + fn clear_current_locked(&mut self) -> bool { + let Some(wan_iface) = self.wan_iface.clone() else { + return self.applied.is_empty(); + }; + + match list_ipv6_ndp_proxy(wan_iface.as_str()) { + Ok(current) => { + let candidates = self.applied.iter().copied().collect::>(); + clear_owned_ndp_proxy_entries( + wan_iface.as_str(), + ¤t, + &mut self.applied, + candidates, + ); + } + Err(err) if is_linux_missing_netlink_object_error(&err) => { + tracing::trace!( + wan_iface = %wan_iface, + ?err, + "forgetting NDP proxy ownership because WAN interface is gone" + ); + self.applied.clear(); + } + Err(err) => { + tracing::trace!( + wan_iface = %wan_iface, + ?err, + "failed to list NDP proxy entries before cleanup" + ); + } + } + + if self.applied.is_empty() { + self.wan_iface = None; + true + } else { + false + } + } + + fn cleanup_pending(&self) -> bool { + self.wan_iface.is_some() && !self.applied.is_empty() + } +} + +#[cfg(target_os = "linux")] +fn is_linux_missing_netlink_object_error(err: &Error) -> bool { + match err { + Error::IOError(err) => { + err.kind() == std::io::ErrorKind::NotFound + || matches!( + err.raw_os_error(), + Some(nix::libc::ESRCH | nix::libc::ENODEV | nix::libc::ENXIO) + ) + } + _ => false, + } +} + +#[cfg(target_os = "linux")] +fn clear_owned_ndp_proxy_entries( + wan_iface: &str, + current: &std::collections::BTreeSet, + applied: &mut std::collections::BTreeSet, + candidates: Vec, +) -> Option { + let mut first_err = None; + for addr in candidates { + if !current.contains(&addr) { + applied.remove(&addr); + continue; + } + + if let Err(err) = remove_ipv6_ndp_proxy(wan_iface, addr) { + if is_linux_missing_netlink_object_error(&err) { + applied.remove(&addr); + } else { + tracing::trace!( + wan_iface = %wan_iface, + addr = %addr, + ?err, + "failed to remove NDP proxy entry" + ); + first_err.get_or_insert(err); + } + } else { + applied.remove(&addr); + } + } + first_err +} + +#[cfg(target_os = "linux")] +fn ndp_proxy_target(state: &PublicIpv6ProviderRuntimeState) -> Option<(Ipv6Cidr, &NdpProxyTarget)> { + match state { + PublicIpv6ProviderRuntimeState::Active(active) => active + .ndp_proxy + .as_ref() + .map(|target| (active.prefix, target)), + PublicIpv6ProviderRuntimeState::Disabled | PublicIpv6ProviderRuntimeState::Pending(_) => { + None + } + } +} + +#[cfg(target_os = "linux")] +fn ensure_linux_ndp_proxy_enabled(wan_iface: &str) -> Result<(), Error> { + let path = Path::new("/proc/sys/net/ipv6/conf") + .join(wan_iface) + .join("proxy_ndp"); + if !read_linux_proc_bool(&path)? { + write_linux_proc_bool(&path, true)?; + tracing::info!(wan_iface = %wan_iface, "enabled Linux NDP proxy"); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn collect_public_ipv6_tun_routes( + tun_iface: &str, + prefix: Ipv6Cidr, +) -> Result, Error> { + let tun_ifindex = match get_interface_index(tun_iface) { + Ok(ifindex) => ifindex, + Err(err) if is_linux_missing_netlink_object_error(&err) => { + tracing::debug!( + tun_iface = %tun_iface, + ?err, + "treating missing tun interface as empty public IPv6 route set" + ); + return Ok(Default::default()); + } + Err(err) => return Err(err), + }; + Ok(list_ipv6_route_messages()? + .into_iter() + .filter(|route| { + route.header.destination_prefix_length == 128 && route.header.kind == RouteType::Unicast + }) + .filter(|route| { + route + .attributes + .iter() + .any(|attr| matches!(attr, RouteAttribute::Oif(idx) if *idx == tun_ifindex)) + }) + .filter_map(|route| { + route.attributes.into_iter().find_map(|attr| match attr { + RouteAttribute::Destination(RouteAddress::Inet6(addr)) => Some(addr), + _ => None, + }) + }) + .filter(|addr| !addr.is_unicast_link_local() && prefix.contains(addr)) + .collect()) +} + +#[cfg(target_os = "linux")] +fn sync_ndp_proxy_entries( + wan_iface: &str, + tun_iface: &str, + prefix: Ipv6Cidr, + applied: &mut std::collections::BTreeSet, +) -> Result<(), Error> { + ensure_linux_ndp_proxy_enabled(wan_iface)?; + + let wanted = collect_public_ipv6_tun_routes(tun_iface, prefix)?; + let current = list_ipv6_ndp_proxy(wan_iface)?; + + let mut first_err = None; + for addr in wanted.difference(¤t) { + if let Err(err) = add_ipv6_ndp_proxy(wan_iface, *addr) { + first_err.get_or_insert(err); + } else { + applied.insert(*addr); + tracing::debug!(wan_iface = %wan_iface, addr = %addr, "added NDP proxy entry"); + } + } + + let stale = applied.difference(&wanted).copied().collect::>(); + let stale_cleanup_err = + clear_owned_ndp_proxy_entries(wan_iface, ¤t, applied, stale.clone()); + if !stale.is_empty() { + tracing::debug!( + wan_iface = %wan_iface, + stale_count = stale.len(), + remaining_count = stale.iter().filter(|addr| applied.contains(addr)).count(), + "synced stale NDP proxy entries" + ); + } + if let Some(err) = first_err.or(stale_cleanup_err) { + return Err(err); + } + + Ok(()) +} + +#[cfg(target_os = "linux")] +fn reconcile_ndp_proxy_runtime( + runtime: &mut NdpProxyRuntime, + global_ctx: &ArcGlobalCtx, + state: &PublicIpv6ProviderRuntimeState, +) -> bool { + runtime.reconcile(global_ctx, state) +} + +#[cfg(target_os = "linux")] +fn cleanup_ndp_proxy_runtime(runtime: &mut NdpProxyRuntime, net_ns: &NetNS) { + if !runtime.clear_current_in_netns(net_ns) { + tracing::warn!( + remaining_entries = runtime.applied.len(), + wan_iface = ?runtime.wan_iface, + "failed to clean all NDP proxy entries before stopping public IPv6 provider task" + ); + } +} + +#[cfg(not(target_os = "linux"))] +fn reconcile_ndp_proxy_runtime( + _runtime: &mut (), + _global_ctx: &ArcGlobalCtx, + _state: &PublicIpv6ProviderRuntimeState, +) -> bool { + false +} + +#[cfg(not(target_os = "linux"))] +fn cleanup_ndp_proxy_runtime(_runtime: &mut (), _net_ns: &NetNS) {} + +#[cfg(target_os = "linux")] +fn new_ndp_proxy_runtime() -> NdpProxyRuntime { + NdpProxyRuntime::default() +} + +#[cfg(not(target_os = "linux"))] +fn new_ndp_proxy_runtime() {} + +fn should_reconcile_immediately(event: &GlobalCtxEvent) -> bool { + matches!( + event, + GlobalCtxEvent::ConfigPatched(_) + | GlobalCtxEvent::TunDeviceReady(_) + | GlobalCtxEvent::TunDeviceError(_) + | GlobalCtxEvent::PublicIpv6RoutesUpdated(_, _) + ) +} + +async fn wait_for_public_ipv6_provider_reconcile_event( + event_receiver: &mut tokio::sync::broadcast::Receiver, + cancel_token: &CancellationToken, + reconcile_interval: std::time::Duration, +) -> bool { + let timer = tokio::time::sleep(reconcile_interval); + tokio::pin!(timer); + loop { + tokio::select! { + _ = cancel_token.cancelled() => return false, + _ = &mut timer => return true, + recv = event_receiver.recv() => match recv { + Ok(event) if should_reconcile_immediately(&event) => return true, + Ok(_) => {} + Err(tokio::sync::broadcast::error::RecvError::Closed) => return false, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + *event_receiver = event_receiver.resubscribe(); + return true; + } + } + } + } +} + +fn log_public_ipv6_provider_state_change( + last_state: Option<&PublicIpv6ProviderRuntimeState>, + next_state: &PublicIpv6ProviderRuntimeState, + changed: bool, +) { + if last_state != Some(next_state) { + match next_state { + PublicIpv6ProviderRuntimeState::Disabled if last_state.is_some() => { + tracing::info!("public IPv6 provider disabled"); + } + PublicIpv6ProviderRuntimeState::Disabled => {} + PublicIpv6ProviderRuntimeState::Pending(reason) => { + tracing::warn!(reason = %reason, "public IPv6 provider not ready"); + } + PublicIpv6ProviderRuntimeState::Active(active) => { + #[cfg(target_os = "linux")] + { + if let Some(target) = active.ndp_proxy.as_ref() { + tracing::info!( + prefix = %active.prefix, + wan_iface = %target.wan_iface, + "public IPv6 provider is active with NDP proxy" + ); + } else { + tracing::info!( + prefix = %active.prefix, + "public IPv6 provider is active" + ); + } + } + #[cfg(not(target_os = "linux"))] + tracing::info!(prefix = %active.prefix, "public IPv6 provider is active"); + } + } + } else if changed { + tracing::info!("public IPv6 provider runtime state changed"); + } +} + +pub(super) struct PublicIpv6ProviderReconcileTask { + cancel_token: CancellationToken, + handle: tokio::task::JoinHandle<()>, +} + +impl PublicIpv6ProviderReconcileTask { + pub(super) async fn shutdown(self) { + self.cancel_token.cancel(); + if let Err(err) = self.handle.await { + tracing::warn!( + ?err, + "public IPv6 provider reconcile task failed during shutdown" + ); + } + } +} + +pub(super) fn run_public_ipv6_provider_reconcile_task( + global_ctx: &ArcGlobalCtx, +) -> Option { if !should_run_public_ipv6_provider_reconcile_task(read_public_ipv6_provider_config_snapshot( global_ctx, )) { - return; + return None; } let global_ctx = Arc::downgrade(global_ctx); - tokio::spawn(async move { + let cancel_token = CancellationToken::new(); + let task_cancel_token = cancel_token.clone(); + let handle = tokio::spawn(async move { let Some(initial_ctx) = global_ctx.upgrade() else { return; }; + let net_ns = initial_ctx.net_ns.clone(); let mut event_receiver = initial_ctx.subscribe(); + drop(initial_ctx); let mut last_state: Option = None; + let mut ndp_proxy_runtime = new_ndp_proxy_runtime(); loop { let Some(global_ctx) = global_ctx.upgrade() else { tracing::debug!("global ctx dropped, stopping public ipv6 provider reconcile"); - return; + break; }; let (next_state, changed) = reconcile_public_ipv6_provider_runtime_with_state(&global_ctx).await; - if last_state.as_ref() != Some(&next_state) { - match &next_state { - PublicIpv6ProviderRuntimeState::Disabled if last_state.is_some() => { - tracing::info!("public IPv6 provider disabled"); - } - PublicIpv6ProviderRuntimeState::Disabled => {} - PublicIpv6ProviderRuntimeState::Pending(reason) => { - tracing::warn!(reason = %reason, "public IPv6 provider not ready"); - } - PublicIpv6ProviderRuntimeState::Active(prefix) => { - tracing::info!(prefix = %prefix, "public IPv6 provider is active"); - } - } - } else if changed { - tracing::info!("public IPv6 provider runtime state changed"); - } + log_public_ipv6_provider_state_change(last_state.as_ref(), &next_state, changed); + let _ = reconcile_ndp_proxy_runtime(&mut ndp_proxy_runtime, &global_ctx, &next_state); last_state = Some(next_state); - if matches!( - last_state.as_ref(), - Some(PublicIpv6ProviderRuntimeState::Disabled) - ) { - match event_receiver.recv().await { - Ok(GlobalCtxEvent::ConfigPatched(_)) => {} - Ok(_) => {} - Err(tokio::sync::broadcast::error::RecvError::Closed) => return, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - event_receiver = event_receiver.resubscribe(); - } - } - } else { - tokio::select! { - recv = event_receiver.recv() => match recv { - Ok(GlobalCtxEvent::ConfigPatched(_)) => {} - Ok(_) => {} - Err(tokio::sync::broadcast::error::RecvError::Closed) => return, - Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - event_receiver = event_receiver.resubscribe(); - } - }, - _ = tokio::time::sleep(PUBLIC_IPV6_PROVIDER_RECONCILE_INTERVAL) => {} - } + if !wait_for_public_ipv6_provider_reconcile_event( + &mut event_receiver, + &task_cancel_token, + PUBLIC_IPV6_PROVIDER_RECONCILE_INTERVAL, + ) + .await + { + break; } } + + cleanup_ndp_proxy_runtime(&mut ndp_proxy_runtime, &net_ns); }); + Some(PublicIpv6ProviderReconcileTask { + cancel_token, + handle, + }) } #[cfg(test)] @@ -515,21 +1123,26 @@ mod tests { #[cfg(target_os = "linux")] use super::{ - DetectedIpv6Route, detect_public_ipv6_prefix_from_routes, detect_public_ipv6_prefix_linux, - ensure_linux_ipv6_forwarding_at_paths, ensure_public_ipv6_provider_supported, - public_ipv6_provider_auto_detect_error, + DefaultRouteIpv6InterfaceCandidate, DetectedIpv6Route, + detect_public_ipv6_prefix_from_interfaces, detect_public_ipv6_prefix_from_routes, + detect_public_ipv6_prefix_linux, ensure_linux_ipv6_forwarding_at_paths, + ensure_public_ipv6_provider_supported, public_ipv6_provider_auto_detect_error, + select_default_route_ipv6_interfaces, + select_public_ipv6_prefix_from_default_route_interfaces, sync_ndp_proxy_entries, }; use super::{ PublicIpv6ProviderConfigSnapshot, PublicIpv6ProviderRuntimeState, - read_public_ipv6_provider_config_snapshot, should_run_public_ipv6_provider_reconcile_task, + active_public_ipv6_provider_state, read_public_ipv6_provider_config_snapshot, + should_run_public_ipv6_provider_reconcile_task, try_apply_public_ipv6_provider_runtime_state, }; #[cfg(not(target_os = "linux"))] use super::{ensure_public_ipv6_provider_supported, public_ipv6_provider_auto_detect_error}; use crate::common::{ config::{ConfigLoader, TomlConfigLoader}, - global_ctx::GlobalCtx, + error::Error, + global_ctx::{GlobalCtx, GlobalCtxEvent}, }; #[cfg(target_os = "linux")] @@ -606,6 +1219,39 @@ mod tests { } } + fn active_state(prefix: cidr::Ipv6Cidr) -> PublicIpv6ProviderRuntimeState { + #[cfg(target_os = "linux")] + { + active_public_ipv6_provider_state(prefix, None) + } + #[cfg(not(target_os = "linux"))] + { + active_public_ipv6_provider_state(prefix) + } + } + + #[cfg(target_os = "linux")] + fn detected_prefix( + detected: Option, + ) -> Option { + detected.map(|detected| detected.prefix) + } + + #[cfg(target_os = "linux")] + fn iface_candidate( + interface_name: &str, + ifindex: u32, + address: &str, + prefix_len: u8, + ) -> DefaultRouteIpv6InterfaceCandidate { + DefaultRouteIpv6InterfaceCandidate { + interface_name: interface_name.to_string(), + ifindex, + address: address.parse().unwrap(), + prefix_len, + } + } + #[cfg(target_os = "linux")] #[test] fn test_detect_public_ipv6_prefix_from_routes_selects_delegated_prefix() { @@ -615,7 +1261,7 @@ mod tests { ]; assert_eq!( - detect_public_ipv6_prefix_from_routes(&routes, 1), + detected_prefix(detect_public_ipv6_prefix_from_routes(&routes, 1)), Some("2001:db8:1::/56".parse().unwrap()) ); } @@ -634,7 +1280,10 @@ mod tests { route(Some("::/0"), None, Some(9), RouteType::Unicast), ]; - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + assert_eq!( + detected_prefix(detect_public_ipv6_prefix_from_routes(&routes, 1)), + None + ); } #[cfg(target_os = "linux")] @@ -647,7 +1296,24 @@ mod tests { RouteType::Unicast, )]; - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + assert_eq!( + detected_prefix(detect_public_ipv6_prefix_from_routes(&routes, 1)), + None + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_public_ipv6_prefix_from_routes_rejects_non_unicast_default_route() { + let routes = vec![ + route(None, Some("2001:db8:1::/56"), Some(2), RouteType::BlackHole), + route(Some("2001:db8:1::/56"), None, Some(3), RouteType::Unicast), + ]; + + assert_eq!( + detected_prefix(detect_public_ipv6_prefix_from_routes(&routes, 1)), + None + ); } #[cfg(target_os = "linux")] @@ -658,7 +1324,10 @@ mod tests { route(Some("2001:db8:1::/56"), None, Some(1), RouteType::Unicast), ]; - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + assert_eq!( + detected_prefix(detect_public_ipv6_prefix_from_routes(&routes, 1)), + None + ); } #[cfg(target_os = "linux")] @@ -672,7 +1341,7 @@ mod tests { ]; assert_eq!( - detect_public_ipv6_prefix_from_routes(&routes, 1), + detected_prefix(detect_public_ipv6_prefix_from_routes(&routes, 1)), Some("2001:db8::/48".parse().unwrap()) ); } @@ -685,7 +1354,10 @@ mod tests { route(Some("2001:db8:1::/56"), None, Some(3), RouteType::BlackHole), ]; - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + assert_eq!( + detected_prefix(detect_public_ipv6_prefix_from_routes(&routes, 1)), + None + ); } #[test] @@ -718,14 +1390,14 @@ mod tests { } #[test] - fn test_reconcile_task_only_runs_for_auto_detect_provider() { + fn test_reconcile_task_runs_when_provider_enabled() { assert!(!should_run_public_ipv6_provider_reconcile_task( PublicIpv6ProviderConfigSnapshot { provider_enabled: false, configured_prefix: None, } )); - assert!(!should_run_public_ipv6_provider_reconcile_task( + assert!(should_run_public_ipv6_provider_reconcile_task( PublicIpv6ProviderConfigSnapshot { provider_enabled: true, configured_prefix: Some("2001:db8::/48".parse().unwrap()), @@ -754,7 +1426,7 @@ mod tests { let changed = try_apply_public_ipv6_provider_runtime_state( &global_ctx, config, - &PublicIpv6ProviderRuntimeState::Active(prefix), + &active_state(prefix), ); assert_eq!(changed, None); @@ -773,7 +1445,7 @@ mod tests { let changed = try_apply_public_ipv6_provider_runtime_state( &global_ctx, config, - &PublicIpv6ProviderRuntimeState::Active(prefix), + &active_state(prefix), ); assert_eq!(changed, Some(true)); @@ -854,11 +1526,474 @@ mod tests { run_ip(&["-6", "route", "add", "2001:db8:100::/56", "dev", &lan_if]); assert_eq!( - detect_public_ipv6_prefix_linux().await.unwrap(), + detected_prefix(detect_public_ipv6_prefix_linux().await.unwrap()), Some("2001:db8:100::/56".parse().unwrap()) ); } + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_detect_public_ipv6_prefix_linux_dhcpv6_ia_na_fallback() { + // DHCPv6 IA_NA scenario: prefix is directly on the WAN interface, + // with no delegated route on a LAN interface. + // The route-based detection should fail, and the interface-scanning + // fallback should pick up the prefix from the WAN address. + let wan_if = test_iface_name("ia"); + let _wan = ScopedDummyLink::new(&wan_if); + + run_ip(&[ + "-6", + "addr", + "add", + "2001:db8:aaaa:ffff::1/64", + "dev", + &wan_if, + ]); + run_ip(&[ + "-6", + "route", + "add", + "default", + "from", + "2001:db8:aaaa::/64", + "dev", + &wan_if, + ]); + // Also add a /48 address+route pair to verify shortest-prefix preference + run_ip(&["-6", "addr", "add", "2001:db8:bbbb::1/48", "dev", &wan_if]); + run_ip(&[ + "-6", + "route", + "add", + "default", + "from", + "2001:db8::/48", + "dev", + &wan_if, + ]); + + // NO delegated route on a LAN interface — this is the IA_NA case + // The fallback should find both prefixes via interface scanning and + // prefer the shorter /48. + let detected = detect_public_ipv6_prefix_linux().await.unwrap().unwrap(); + assert_eq!(detected.prefix, "2001:db8:bbbb::/48".parse().unwrap()); + assert_eq!(detected.ndp_proxy.unwrap().wan_iface, wan_if); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_detect_public_ipv6_prefix_from_interfaces_uses_default_route_iface() { + let wan_if = test_iface_name("dw"); + let other_if = test_iface_name("do"); + let _wan = ScopedDummyLink::new(&wan_if); + let _other = ScopedDummyLink::new(&other_if); + + run_ip(&["-6", "addr", "add", "2001:db8:dddd::1/64", "dev", &wan_if]); + run_ip(&["-6", "addr", "add", "2001:db8::1/48", "dev", &other_if]); + + let wan_ifindex = crate::common::ifcfg::get_interface_index(&wan_if).unwrap(); + let other_ifindex = crate::common::ifcfg::get_interface_index(&other_if).unwrap(); + let routes = vec![ + route(None, None, Some(wan_ifindex), RouteType::Unicast), + route( + Some("2001:db8::/48"), + None, + Some(other_ifindex), + RouteType::Unicast, + ), + ]; + + let detected = detect_public_ipv6_prefix_from_interfaces(&routes) + .expect("fallback should select the default-route interface"); + assert_eq!(detected.prefix, "2001:db8:dddd::/64".parse().unwrap()); + assert_eq!(detected.ndp_proxy.unwrap().wan_iface, wan_if); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_select_default_route_ipv6_interfaces_filters_candidates() { + let wan_ifindices = [2u32].into_iter().collect(); + let candidates = vec![ + iface_candidate("wan0", 2, "2001:db8:100::1", 64), + iface_candidate("nonwan0", 9, "2001:db8:200::1", 64), + iface_candidate("loopback0", 2, "::1", 128), + iface_candidate("linklocal0", 2, "fe80::1", 64), + iface_candidate("ula0", 2, "fd00::1", 64), + iface_candidate("multicast0", 2, "ff02::1", 64), + iface_candidate("unspecified0", 2, "::", 64), + iface_candidate("empty0", 2, "2001:db8:300::1", 0), + iface_candidate("host0", 2, "2001:db8:400::1", 128), + ]; + + let selected = select_default_route_ipv6_interfaces(candidates, &wan_ifindices, 64); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].interface_name, "wan0"); + assert_eq!(selected[0].prefix, "2001:db8:100::/64".parse().unwrap()); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_select_default_route_ipv6_interfaces_strips_host_bits() { + let wan_ifindices = [2u32].into_iter().collect(); + let candidates = vec![iface_candidate("wan0", 2, "2001:db8:aaaa::abcd", 64)]; + + let selected = select_default_route_ipv6_interfaces(candidates, &wan_ifindices, 64); + + assert_eq!(selected.len(), 1); + assert_eq!(selected[0].prefix, "2001:db8:aaaa::/64".parse().unwrap()); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_select_public_ipv6_prefix_tie_breaks_by_lowest_ifindex() { + let wan_ifindices = [2u32, 3, 5].into_iter().collect(); + let candidates = vec![ + iface_candidate("wan5", 5, "2001:db8:5555::1", 48), + iface_candidate("wan64", 3, "2001:db8:3333::1", 64), + iface_candidate("wan2", 2, "2001:db9:2222::1", 48), + ]; + let interfaces = select_default_route_ipv6_interfaces(candidates, &wan_ifindices, 64); + + let detected = select_public_ipv6_prefix_from_default_route_interfaces(interfaces) + .expect("default-route public IPv6 prefix should be selected"); + + assert_eq!(detected.prefix, "2001:db9:2222::/48".parse().unwrap()); + assert_eq!(detected.ndp_proxy.unwrap().wan_iface, "wan2"); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_configured_prefix_on_default_iface_gets_ndp_proxy_target() { + let wan_if = test_iface_name("cp"); + let _wan = ScopedDummyLink::new(&wan_if); + let configured_prefix = "2001:db8:feed::/64".parse().unwrap(); + + run_ip(&["-6", "addr", "add", "2001:db8:feed::1/128", "dev", &wan_if]); + + let ifindex = crate::common::ifcfg::get_interface_index(&wan_if).unwrap(); + let routes = vec![route(None, None, Some(ifindex), RouteType::Unicast)]; + + let target = super::detect_configured_prefix_ndp_proxy_target(&routes, configured_prefix) + .expect("configured on-link prefix should require NDP proxy"); + assert_eq!(target.wan_iface, wan_if); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_configured_prefix_broader_than_default_iface_does_not_get_ndp_proxy_target() { + let wan_if = test_iface_name("cb"); + let _wan = ScopedDummyLink::new(&wan_if); + let configured_prefix = "2001:db8:beef::/48".parse().unwrap(); + + run_ip(&["-6", "addr", "add", "2001:db8:beef:1::1/64", "dev", &wan_if]); + + let ifindex = crate::common::ifcfg::get_interface_index(&wan_if).unwrap(); + let routes = vec![route(None, None, Some(ifindex), RouteType::Unicast)]; + + assert_eq!( + super::detect_configured_prefix_ndp_proxy_target(&routes, configured_prefix), + None + ); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_detect_public_ipv6_prefix_linux_dhcpv6_ia_na_single_prefix() { + // DHCPv6 IA_NA: the WAN interface has a global prefix with a + // default route. Use a /48 dummy prefix so the fallback prefers it + // over any real /64 on the test machine. + let wan_if = test_iface_name("ib"); + let _wan = ScopedDummyLink::new(&wan_if); + + run_ip(&["-6", "addr", "add", "2001:db8:cccc::1/48", "dev", &wan_if]); + run_ip(&["-6", "route", "add", "default", "dev", &wan_if]); + + let detected = detect_public_ipv6_prefix_linux().await.unwrap().unwrap(); + assert_eq!(detected.prefix, "2001:db8:cccc::/48".parse().unwrap()); + assert_eq!(detected.ndp_proxy.unwrap().wan_iface, wan_if); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_detect_public_ipv6_prefix_from_interfaces_skips_non_global() { + // Create a dummy interface with only a link-local address. + // The interface fallback should return None because there is no + // global unicast address. + let iface = test_iface_name("ng"); + let _link = ScopedDummyLink::new(&iface); + + // Bring up the interface so it auto-configures a link-local address + run_ip(&["link", "set", &iface, "up"]); + let ifindex = crate::common::ifcfg::get_interface_index(&iface).unwrap(); + let routes = vec![route(None, None, Some(ifindex), RouteType::Unicast)]; + + // No global address added — only link-local should be present + let result = detect_public_ipv6_prefix_from_interfaces(&routes); + assert_eq!(detected_prefix(result), None); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_ndp_proxy_sync_uses_configured_tun_iface_without_shell_neigh() { + let wan_if = test_iface_name("nw"); + let tun_if = test_iface_name("nt"); + let _wan = ScopedDummyLink::new(&wan_if); + let _tun = ScopedDummyLink::new(&tun_if); + let addr = "2001:db8:abcd::123".parse::().unwrap(); + let prefix = "2001:db8:abcd::/64".parse().unwrap(); + let mut applied = std::collections::BTreeSet::new(); + + run_ip(&["-6", "route", "add", &format!("{addr}/128"), "dev", &tun_if]); + + sync_ndp_proxy_entries(&wan_if, &tun_if, prefix, &mut applied).unwrap(); + assert!( + crate::common::ifcfg::list_ipv6_ndp_proxy(&wan_if) + .unwrap() + .contains(&addr) + ); + assert!(applied.contains(&addr)); + + run_ip(&["-6", "route", "del", &format!("{addr}/128"), "dev", &tun_if]); + sync_ndp_proxy_entries(&wan_if, &tun_if, prefix, &mut applied).unwrap(); + assert!( + !crate::common::ifcfg::list_ipv6_ndp_proxy(&wan_if) + .unwrap() + .contains(&addr) + ); + assert!(!applied.contains(&addr)); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_ndp_proxy_sync_does_not_delete_preexisting_proxy_entry() { + let wan_if = test_iface_name("pw"); + let tun_if = test_iface_name("pt"); + let _wan = ScopedDummyLink::new(&wan_if); + let _tun = ScopedDummyLink::new(&tun_if); + let addr = "2001:db8:beef::123".parse::().unwrap(); + let prefix = "2001:db8:beef::/64".parse().unwrap(); + let mut applied = std::collections::BTreeSet::new(); + + super::ensure_linux_ndp_proxy_enabled(&wan_if).unwrap(); + crate::common::ifcfg::add_ipv6_ndp_proxy(&wan_if, addr).unwrap(); + run_ip(&["-6", "route", "add", &format!("{addr}/128"), "dev", &tun_if]); + + sync_ndp_proxy_entries(&wan_if, &tun_if, prefix, &mut applied).unwrap(); + assert!( + crate::common::ifcfg::list_ipv6_ndp_proxy(&wan_if) + .unwrap() + .contains(&addr) + ); + assert!(!applied.contains(&addr)); + + run_ip(&["-6", "route", "del", &format!("{addr}/128"), "dev", &tun_if]); + sync_ndp_proxy_entries(&wan_if, &tun_if, prefix, &mut applied).unwrap(); + assert!( + crate::common::ifcfg::list_ipv6_ndp_proxy(&wan_if) + .unwrap() + .contains(&addr) + ); + assert!(!applied.contains(&addr)); + + crate::common::ifcfg::remove_ipv6_ndp_proxy(&wan_if, addr).unwrap(); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_ndp_proxy_sync_removes_owned_entry_when_tun_iface_is_gone() { + let wan_if = test_iface_name("gw"); + let _wan = ScopedDummyLink::new(&wan_if); + let addr = "2001:db8:face::123".parse::().unwrap(); + let prefix = "2001:db8:face::/64".parse().unwrap(); + let mut applied = std::collections::BTreeSet::from([addr]); + + super::ensure_linux_ndp_proxy_enabled(&wan_if).unwrap(); + crate::common::ifcfg::add_ipv6_ndp_proxy(&wan_if, addr).unwrap(); + + sync_ndp_proxy_entries(&wan_if, "missing-easytier-tun", prefix, &mut applied).unwrap(); + assert!( + !crate::common::ifcfg::list_ipv6_ndp_proxy(&wan_if) + .unwrap() + .contains(&addr) + ); + assert!(applied.is_empty()); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_cleanup_ndp_proxy_runtime_removes_owned_entry_on_task_exit() { + let wan_if = test_iface_name("cw"); + let _wan = ScopedDummyLink::new(&wan_if); + let addr = "2001:db8:cafe::123".parse::().unwrap(); + let mut runtime = super::NdpProxyRuntime { + wan_iface: Some(wan_if.clone()), + applied: std::collections::BTreeSet::from([addr]), + }; + + super::ensure_linux_ndp_proxy_enabled(&wan_if).unwrap(); + crate::common::ifcfg::add_ipv6_ndp_proxy(&wan_if, addr).unwrap(); + + super::cleanup_ndp_proxy_runtime(&mut runtime, &crate::common::netns::NetNS::new(None)); + assert!( + !crate::common::ifcfg::list_ipv6_ndp_proxy(&wan_if) + .unwrap() + .contains(&addr) + ); + assert!(!runtime.cleanup_pending()); + } + + #[tokio::test] + async fn test_wait_for_reconcile_ignores_unrelated_events_without_resetting_timer() { + let (tx, mut rx) = tokio::sync::broadcast::channel(16); + let cancel_token = tokio_util::sync::CancellationToken::new(); + let spam_task = tokio::spawn(async move { + loop { + if tx.send(GlobalCtxEvent::PeerAdded(1)).is_err() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + }); + + let reconciled = tokio::time::timeout( + std::time::Duration::from_millis(250), + super::wait_for_public_ipv6_provider_reconcile_event( + &mut rx, + &cancel_token, + std::time::Duration::from_millis(50), + ), + ) + .await + .expect("unrelated events should not keep resetting the reconcile timer"); + + spam_task.abort(); + assert!(reconciled); + } + + #[cfg(target_os = "linux")] + async fn wait_for_ndp_proxy_entry(wan_if: &str, addr: std::net::Ipv6Addr, present: bool) { + for _ in 0..50 { + let current = crate::common::ifcfg::list_ipv6_ndp_proxy(wan_if).unwrap(); + if current.contains(&addr) == present { + return; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + + let current = crate::common::ifcfg::list_ipv6_ndp_proxy(wan_if).unwrap(); + assert_eq!(current.contains(&addr), present); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_reconcile_task_shutdown_removes_owned_ndp_proxy_entry() { + let wan_if = test_iface_name("tw"); + let tun_if = test_iface_name("tt"); + let _wan = ScopedDummyLink::new(&wan_if); + let _tun = ScopedDummyLink::new(&tun_if); + let prefix = "2001:db8:fade::/64".parse().unwrap(); + let wan_addr = "2001:db8:fade::1"; + let leased_addr = "2001:db8:fade::123".parse::().unwrap(); + let global_ctx = test_global_ctx(); + + run_ip(&[ + "-6", + "addr", + "add", + &format!("{wan_addr}/128"), + "dev", + &wan_if, + ]); + run_ip(&["-6", "route", "add", "default", "dev", &wan_if]); + run_ip(&[ + "-6", + "route", + "add", + &format!("{leased_addr}/128"), + "dev", + &tun_if, + ]); + + global_ctx.config.set_ipv6_public_addr_provider(true); + global_ctx.config.set_ipv6_public_addr_prefix(Some(prefix)); + global_ctx.set_tun_device_ready(tun_if); + + let task = super::run_public_ipv6_provider_reconcile_task(&global_ctx) + .expect("provider task should start when provider is enabled"); + wait_for_ndp_proxy_entry(&wan_if, leased_addr, true).await; + + task.shutdown().await; + wait_for_ndp_proxy_entry(&wan_if, leased_addr, false).await; + } + + #[cfg(target_os = "linux")] + #[test] + fn test_missing_netlink_object_errors_release_ndp_ownership() { + for errno in [ + nix::libc::ENOENT, + nix::libc::ESRCH, + nix::libc::ENODEV, + nix::libc::ENXIO, + ] { + let err = Error::IOError(std::io::Error::from_raw_os_error(errno)); + assert!(super::is_linux_missing_netlink_object_error(&err)); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn test_clear_owned_ndp_proxy_entries_forgets_already_absent_entry() { + let addr = "2001:db8:dead::111".parse::().unwrap(); + let mut applied = std::collections::BTreeSet::from([addr]); + let current = std::collections::BTreeSet::new(); + + assert!( + super::clear_owned_ndp_proxy_entries("missing", ¤t, &mut applied, vec![addr],) + .is_none() + ); + assert!(!applied.contains(&addr)); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_ndp_proxy_runtime_forgets_entries_when_wan_interface_is_gone() { + let addr = "2001:db8:dead::123".parse::().unwrap(); + let mut runtime = super::NdpProxyRuntime { + wan_iface: Some(test_iface_name("missing")), + applied: std::collections::BTreeSet::from([addr]), + }; + + assert!(runtime.clear_current_locked()); + assert!(runtime.wan_iface.is_none()); + assert!(runtime.applied.is_empty()); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn test_ndp_proxy_runtime_finishes_cleanup_when_wan_interface_is_gone_after_disable() { + let addr = "2001:db8:dead::456".parse::().unwrap(); + let global_ctx = test_global_ctx(); + let mut runtime = super::NdpProxyRuntime { + wan_iface: Some(test_iface_name("missing")), + applied: std::collections::BTreeSet::from([addr]), + }; + + assert!(!runtime.reconcile(&global_ctx, &PublicIpv6ProviderRuntimeState::Disabled)); + assert!(!runtime.cleanup_pending()); + } + #[cfg(target_os = "linux")] #[serial_test::serial] #[tokio::test] @@ -906,7 +2041,7 @@ mod tests { run_ip(&["-6", "route", "add", "2001:db9::/48", "dev", &lan_if_2]); assert_eq!( - detect_public_ipv6_prefix_linux().await.unwrap(), + detected_prefix(detect_public_ipv6_prefix_linux().await.unwrap()), Some("2001:db9::/48".parse().unwrap()) ); } diff --git a/easytier/src/instance/virtual_nic.rs b/easytier/src/instance/virtual_nic.rs index 1faa1fd5..743c507f 100644 --- a/easytier/src/instance/virtual_nic.rs +++ b/easytier/src/instance/virtual_nic.rs @@ -1361,12 +1361,11 @@ impl NicCtx { } self.global_ctx - .issue_event(GlobalCtxEvent::TunDeviceReady(nic.ifname().to_string())); + .set_tun_device_ready(nic.ifname().to_string()); ret } Err(err) => { - self.global_ctx - .issue_event(GlobalCtxEvent::TunDeviceError(err.to_string())); + self.global_ctx.set_tun_device_error(err.to_string()); return Err(err); } } @@ -1405,12 +1404,11 @@ impl NicCtx { match nic.create_dev_for_mobile(tun_fd).await { Ok(ret) => { self.global_ctx - .issue_event(GlobalCtxEvent::TunDeviceReady(nic.ifname().to_string())); + .set_tun_device_ready(nic.ifname().to_string()); ret } Err(err) => { - self.global_ctx - .issue_event(GlobalCtxEvent::TunDeviceError(err.to_string())); + self.global_ctx.set_tun_device_error(err.to_string()); return Err(err); } } diff --git a/easytier/src/launcher.rs b/easytier/src/launcher.rs index 2a09b84b..2e59b751 100644 --- a/easytier/src/launcher.rs +++ b/easytier/src/launcher.rs @@ -173,7 +173,12 @@ impl EasyTierLauncher { #[cfg(mobile)] Self::run_routine_for_mobile(&instance, &data, &mut tasks).await; - instance.run().await?; + if let Err(err) = instance.run().await { + tasks.abort_all(); + drop(tasks); + instance.clear_resources().await; + return Err(err.into()); + } #[cfg(feature = "ffi-dataplane")] data.data_plane diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index 7bcea386..969188d2 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -477,6 +477,12 @@ struct PublicIpv6Lab { extra_bridges: [&'static str; 2], } +#[derive(Clone, Copy)] +enum PublicIpv6LabTopology { + DelegatedPrefix, + OnLinkPrefix, +} + impl PublicIpv6Lab { const PROVIDER_NS: &'static str = "net_a"; const CLIENT_NS: &'static str = "net_b"; @@ -490,11 +496,13 @@ impl PublicIpv6Lab { const PROVIDER_DEFAULT_FROM: &'static str = "2001:db8:100::/64"; const PROVIDER_WAN_ADDR: &'static str = "2001:db8:ffff:1::2/64"; const UPSTREAM_WAN_ADDR: &'static str = "2001:db8:ffff:1::1/64"; + const ON_LINK_PROVIDER_WAN_ADDR: &'static str = "2001:db8:100::2/64"; + const ON_LINK_UPSTREAM_WAN_ADDR: &'static str = "2001:db8:100::1/64"; const UPSTREAM_SERVER_ADDR: &'static str = "2001:db8:ffff:2::1/64"; const SERVER_ADDR: &'static str = "2001:db8:ffff:2::100/64"; const SERVER_IP: &'static str = "2001:db8:ffff:2::100"; - fn setup() -> Self { + fn setup_with_topology(topology: PublicIpv6LabTopology) -> Self { prepare_linux_namespaces(); del_netns(Self::UPSTREAM_NS); @@ -544,13 +552,23 @@ impl PublicIpv6Lab { Self::SERVER_BRIDGE, ); + let (provider_wan_addr, upstream_wan_addr) = match topology { + PublicIpv6LabTopology::DelegatedPrefix => { + (Self::PROVIDER_WAN_ADDR, Self::UPSTREAM_WAN_ADDR) + } + PublicIpv6LabTopology::OnLinkPrefix => ( + Self::ON_LINK_PROVIDER_WAN_ADDR, + Self::ON_LINK_UPSTREAM_WAN_ADDR, + ), + }; + run_ip_in_ns( Self::PROVIDER_NS, - &["addr", "add", Self::PROVIDER_WAN_ADDR, "dev", "pubwan0"], + &["addr", "add", provider_wan_addr, "dev", "pubwan0"], ); run_ip_in_ns( Self::UPSTREAM_NS, - &["addr", "add", Self::UPSTREAM_WAN_ADDR, "dev", "upwan0"], + &["addr", "add", upstream_wan_addr, "dev", "upwan0"], ); run_ip_in_ns( Self::UPSTREAM_NS, @@ -561,37 +579,56 @@ impl PublicIpv6Lab { &["addr", "add", Self::SERVER_ADDR, "dev", "srv0"], ); - run_ip_in_ns( - Self::PROVIDER_NS, - &["link", "add", "pubprefix0", "type", "dummy"], - ); - run_ip_in_ns(Self::PROVIDER_NS, &["link", "set", "pubprefix0", "up"]); - run_ip_in_ns( - Self::PROVIDER_NS, - &[ - "-6", - "route", - "add", - Self::PROVIDER_PREFIX, - "dev", - "pubprefix0", - ], - ); - run_ip_in_ns( - Self::PROVIDER_NS, - &[ - "-6", - "route", - "add", - "default", - "from", - Self::PROVIDER_DEFAULT_FROM, - "via", - "2001:db8:ffff:1::1", - "dev", - "pubwan0", - ], - ); + match topology { + PublicIpv6LabTopology::DelegatedPrefix => { + run_ip_in_ns( + Self::PROVIDER_NS, + &["link", "add", "pubprefix0", "type", "dummy"], + ); + run_ip_in_ns(Self::PROVIDER_NS, &["link", "set", "pubprefix0", "up"]); + run_ip_in_ns( + Self::PROVIDER_NS, + &[ + "-6", + "route", + "add", + Self::PROVIDER_PREFIX, + "dev", + "pubprefix0", + ], + ); + run_ip_in_ns( + Self::PROVIDER_NS, + &[ + "-6", + "route", + "add", + "default", + "from", + Self::PROVIDER_DEFAULT_FROM, + "via", + "2001:db8:ffff:1::1", + "dev", + "pubwan0", + ], + ); + } + PublicIpv6LabTopology::OnLinkPrefix => { + run_ip_in_ns( + Self::PROVIDER_NS, + &[ + "-6", + "route", + "add", + "default", + "via", + "2001:db8:100::1", + "dev", + "pubwan0", + ], + ); + } + } run_ip_in_ns( Self::SERVER_NS, @@ -606,19 +643,21 @@ impl PublicIpv6Lab { "srv0", ], ); - run_ip_in_ns( - Self::UPSTREAM_NS, - &[ - "-6", - "route", - "add", - Self::PROVIDER_PREFIX, - "via", - "2001:db8:ffff:1::2", - "dev", - "upwan0", - ], - ); + if matches!(topology, PublicIpv6LabTopology::DelegatedPrefix) { + run_ip_in_ns( + Self::UPSTREAM_NS, + &[ + "-6", + "route", + "add", + Self::PROVIDER_PREFIX, + "via", + "2001:db8:ffff:1::2", + "dev", + "upwan0", + ], + ); + } run_sysctl_in_ns(Self::PROVIDER_NS, "net.ipv6.conf.all.forwarding=1"); run_sysctl_in_ns(Self::UPSTREAM_NS, "net.ipv6.conf.all.forwarding=1"); @@ -672,7 +711,15 @@ fn get_public_ipv6_config( async fn init_public_ipv6_two_node( client_inst_id: uuid::Uuid, ) -> (PublicIpv6Lab, Instance, Instance) { - let lab = PublicIpv6Lab::setup(); + init_public_ipv6_two_node_with_topology(client_inst_id, PublicIpv6LabTopology::DelegatedPrefix) + .await +} + +async fn init_public_ipv6_two_node_with_topology( + client_inst_id: uuid::Uuid, + topology: PublicIpv6LabTopology, +) -> (PublicIpv6Lab, Instance, Instance) { + let lab = PublicIpv6Lab::setup_with_topology(topology); let provider_cfg = get_public_ipv6_config( "provider_public_ipv6", @@ -756,6 +803,13 @@ fn addr_exists_in_ns(ns: &str, dev: &str, needle: &str) -> bool { run_ip_in_ns_output(ns, &["-6", "addr", "show", "dev", dev]).contains(needle) } +fn ndp_proxy_exists_in_ns(ns: &str, dev: &str, addr: std::net::Ipv6Addr) -> bool { + let addr = addr.to_string(); + run_ip_in_ns_output(ns, &["-6", "neigh", "show", "proxy", "dev", dev]) + .lines() + .any(|line| line.split_whitespace().next() == Some(addr.as_str())) +} + #[tokio::test] #[serial_test::serial] pub async fn public_ipv6_auto_addr_end_to_end() { @@ -878,6 +932,67 @@ pub async fn public_ipv6_auto_addr_end_to_end() { drop_insts(vec![provider, client]).await; } +#[tokio::test] +#[serial_test::serial] +pub async fn public_ipv6_auto_addr_on_link_ndp_proxy_end_to_end() { + let client_id = uuid::Uuid::parse_str("44444444-4444-4444-4444-444444444444").unwrap(); + let (_lab, provider, client) = + init_public_ipv6_two_node_with_topology(client_id, PublicIpv6LabTopology::OnLinkPrefix) + .await; + + wait_for_condition( + || async { + provider + .get_global_ctx() + .get_advertised_ipv6_public_addr_prefix() + == Some(PublicIpv6Lab::PROVIDER_PREFIX.parse().unwrap()) + }, + Duration::from_secs(10), + ) + .await; + + let leased = wait_for_public_ipv6_addr(&client).await; + wait_for_public_ipv6_route(&provider, leased).await; + + wait_for_condition( + || async { + addr_exists_in_ns( + PublicIpv6Lab::CLIENT_NS, + PublicIpv6Lab::CLIENT_TUN, + &leased.to_string(), + ) && route_exists_in_ns( + PublicIpv6Lab::PROVIDER_NS, + &format!("{} dev {}", leased.address(), PublicIpv6Lab::PROVIDER_TUN), + ) + }, + Duration::from_secs(10), + ) + .await; + + wait_for_condition( + || async { + ndp_proxy_exists_in_ns(PublicIpv6Lab::PROVIDER_NS, "pubwan0", leased.address()) + }, + Duration::from_secs(20), + ) + .await; + + wait_for_condition( + || async { + ping6_test( + PublicIpv6Lab::SERVER_NS, + leased.address().to_string().as_str(), + None, + ) + .await + }, + Duration::from_secs(20), + ) + .await; + + drop_insts(vec![provider, client]).await; +} + #[tokio::test] #[serial_test::serial] pub async fn public_ipv6_auto_addr_reconnect_reuses_same_address() {