From 463228f1decd00b90f1e718826e096f1515ea56f Mon Sep 17 00:00:00 2001 From: "sijie.sun" Date: Sun, 26 Apr 2026 01:18:25 +0800 Subject: [PATCH] allow provider detect dynamically --- easytier/src/common/global_ctx.rs | 16 + easytier/src/instance/instance.rs | 575 +---------- easytier/src/instance/mod.rs | 2 + easytier/src/instance/public_ipv6_provider.rs | 905 ++++++++++++++++++ easytier/src/peers/peer_manager.rs | 8 + easytier/src/peers/peer_ospf_route.rs | 2 +- easytier/src/proto/api_instance.proto | 1 + easytier/src/tests/three_node.rs | 27 +- 8 files changed, 966 insertions(+), 570 deletions(-) create mode 100644 easytier/src/instance/public_ipv6_provider.rs diff --git a/easytier/src/common/global_ctx.rs b/easytier/src/common/global_ctx.rs index feada4e5..7109dd08 100644 --- a/easytier/src/common/global_ctx.rs +++ b/easytier/src/common/global_ctx.rs @@ -211,6 +211,7 @@ pub struct GlobalCtx { stun_info_collection: Mutex>, running_listeners: Mutex>, + advertised_ipv6_public_addr_prefix: Mutex>, flags: ArcSwap, @@ -309,6 +310,7 @@ impl GlobalCtx { stun_info_collection: Mutex::new(stun_info_collector), running_listeners: Mutex::new(Vec::new()), + advertised_ipv6_public_addr_prefix: Mutex::new(None), flags: ArcSwap::new(Arc::new(flags)), @@ -383,6 +385,20 @@ impl GlobalCtx { self.cached_ipv6.store(None); } + pub fn get_advertised_ipv6_public_addr_prefix(&self) -> Option { + *self.advertised_ipv6_public_addr_prefix.lock().unwrap() + } + + pub fn set_advertised_ipv6_public_addr_prefix(&self, prefix: Option) -> bool { + let mut guard = self.advertised_ipv6_public_addr_prefix.lock().unwrap(); + if *guard == prefix { + return false; + } + + *guard = prefix; + true + } + pub fn get_id(&self) -> uuid::Uuid { self.config.get_id() } diff --git a/easytier/src/instance/instance.rs b/easytier/src/instance/instance.rs index 309a36e7..dbb762cc 100644 --- a/easytier/src/instance/instance.rs +++ b/easytier/src/instance/instance.rs @@ -1,21 +1,15 @@ #[cfg(feature = "tun")] use std::any::Any; use std::collections::HashSet; -#[cfg(target_os = "linux")] -use std::net::Ipv6Addr; use std::net::{IpAddr, Ipv4Addr}; -#[cfg(target_os = "linux")] -use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Weak}; #[cfg(feature = "tun")] use std::time::Duration; use anyhow::Context; -use cidr::{IpCidr, Ipv4Inet, Ipv6Cidr}; +use cidr::{IpCidr, Ipv4Inet}; use futures::FutureExt; -#[cfg(target_os = "linux")] -use netlink_packet_route::route::{RouteAddress, RouteAttribute, RouteMessage, RouteType}; use tokio::sync::{Mutex, Notify}; #[cfg(feature = "tun")] use tokio::{sync::oneshot, task::JoinSet}; @@ -28,8 +22,6 @@ use crate::common::acl_processor::AclRuleBuilder; use crate::common::config::ConfigLoader; use crate::common::error::Error; use crate::common::global_ctx::{ArcGlobalCtx, GlobalCtx, GlobalCtxEvent}; -#[cfg(target_os = "linux")] -use crate::common::ifcfg::{get_interface_index, list_ipv6_route_messages}; use crate::connector::direct::DirectConnectorManager; use crate::connector::manual::{ConnectorManagerRpcService, ManualConnectorManager}; use crate::connector::tcp_hole_punch::TcpHolePunchConnector; @@ -72,6 +64,10 @@ use crate::vpn_portal::{self, VpnPortal}; #[cfg(feature = "magic-dns")] 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, + validate_public_ipv6_config, +}; #[cfg(feature = "socks5")] use crate::gateway::socks5::Socks5Server; @@ -202,202 +198,6 @@ pub struct InstanceRpcServerHook { rpc_portal_whitelist: Vec, } -fn is_global_routable_public_ipv6_prefix(prefix: Ipv6Cidr) -> bool { - let addr = prefix.first_address(); - !addr.is_loopback() - && !addr.is_multicast() - && !addr.is_unicast_link_local() - && !addr.is_unique_local() - && !addr.is_unspecified() -} - -fn ensure_public_ipv6_provider_supported() -> Result<(), Error> { - if cfg!(target_os = "linux") { - return Ok(()); - } - - Err(anyhow::anyhow!( - "the provider feature requires Linux; run without --ipv6-public-addr-provider on this node, or move the provider role to a Linux node. client mode (--ipv6-public-addr-auto) works on all platforms" - ) - .into()) -} - -fn public_ipv6_provider_auto_detect_error() -> Error { - anyhow::anyhow!( - "no public IPv6 prefix found on this system; set --ipv6-public-addr-prefix manually, or check that your ISP has delegated an IPv6 prefix and a default-from route exists in the kernel routing table" - ) - .into() -} - -#[cfg(target_os = "linux")] -fn read_linux_proc_bool(path: &Path) -> Result { - let value = std::fs::read_to_string(path) - .with_context(|| format!("failed to read {}", path.display()))?; - match value.trim() { - "0" => Ok(false), - "1" => Ok(true), - other => Err(anyhow::anyhow!("unexpected value '{}' in {}", other, path.display()).into()), - } -} - -#[cfg(target_os = "linux")] -fn write_linux_proc_bool(path: &Path, enabled: bool) -> Result<(), Error> { - let value = if enabled { "1\n" } else { "0\n" }; - std::fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))?; - Ok(()) -} - -#[cfg(target_os = "linux")] -fn ensure_linux_ipv6_forwarding_at_paths( - all_path: &Path, - default_path: &Path, -) -> Result { - let all_enabled = read_linux_proc_bool(all_path)?; - let default_enabled = read_linux_proc_bool(default_path)?; - let mut changed = false; - - if !all_enabled { - write_linux_proc_bool(all_path, true)?; - changed = true; - } - - if !default_enabled { - write_linux_proc_bool(default_path, true)?; - changed = true; - } - - if !read_linux_proc_bool(all_path)? || !read_linux_proc_bool(default_path)? { - return Err(anyhow::anyhow!( - "failed to enable Linux IPv6 forwarding in {} and {}", - all_path.display(), - default_path.display() - ) - .into()); - } - - Ok(changed) -} - -#[cfg(target_os = "linux")] -fn ensure_linux_ipv6_forwarding() -> Result { - let all_path = Path::new("/proc/sys/net/ipv6/conf/all/forwarding"); - let default_path = Path::new("/proc/sys/net/ipv6/conf/default/forwarding"); - - ensure_linux_ipv6_forwarding_at_paths(all_path, default_path).map_err(|err| { - anyhow::anyhow!( - "public IPv6 provider requires Linux IPv6 forwarding; failed to enable net.ipv6.conf.all.forwarding=1 and net.ipv6.conf.default.forwarding=1 automatically: {}. run with sufficient privileges or set them manually", - err - ) - .into() - }) -} - -#[cfg(target_os = "linux")] -#[derive(Clone, Debug, PartialEq, Eq)] -struct DetectedIpv6Route { - dst: Option, - src: Option, - ifindex: Option, - kind: RouteType, -} - -#[cfg(target_os = "linux")] -fn ipv6_cidr_from_route_addr(addr: RouteAddress, prefix_len: u8) -> Option { - match addr { - RouteAddress::Inet6(addr) => Ipv6Cidr::new(addr, prefix_len).ok(), - _ => None, - } -} - -#[cfg(target_os = "linux")] -impl TryFrom for DetectedIpv6Route { - type Error = Error; - - fn try_from(message: RouteMessage) -> Result { - let dst = message.attributes.iter().find_map(|attr| match attr { - RouteAttribute::Destination(addr) => { - ipv6_cidr_from_route_addr(addr.clone(), message.header.destination_prefix_length) - } - _ => None, - }); - let src = message.attributes.iter().find_map(|attr| match attr { - RouteAttribute::Source(addr) => { - ipv6_cidr_from_route_addr(addr.clone(), message.header.source_prefix_length) - } - _ => None, - }); - let ifindex = message.attributes.iter().find_map(|attr| match attr { - RouteAttribute::Oif(index) => Some(*index), - _ => None, - }); - - Ok(Self { - dst, - src, - ifindex, - kind: message.header.kind, - }) - } -} - -#[cfg(target_os = "linux")] -fn is_ipv6_default_route(dst: Option) -> bool { - dst.is_none() || dst == Some(Ipv6Cidr::new(Ipv6Addr::UNSPECIFIED, 0).unwrap()) -} - -#[cfg(target_os = "linux")] -fn detect_public_ipv6_prefix_from_routes( - routes: &[DetectedIpv6Route], - loopback_ifindex: u32, -) -> Option { - routes - .iter() - .filter_map(|route| { - if !is_ipv6_default_route(route.dst) { - return None; - } - - let prefix = route.src?; - let wan_ifindex = route.ifindex?; - if !is_global_routable_public_ipv6_prefix(prefix) { - return None; - } - - let delegated = routes.iter().any(|candidate| { - candidate.dst == Some(prefix) - && candidate.ifindex.is_some() - && candidate.ifindex != Some(wan_ifindex) - && candidate.ifindex != Some(loopback_ifindex) - && candidate.kind == RouteType::Unicast - }); - - delegated.then_some(prefix) - }) - .min_by_key(|prefix| prefix.network_length()) -} - -#[cfg(target_os = "linux")] -async fn detect_public_ipv6_prefix_linux() -> Result, Error> { - let routes = list_ipv6_route_messages().with_context(|| "failed to query linux ipv6 routes")?; - let routes = routes - .iter() - .cloned() - .map(DetectedIpv6Route::try_from) - .collect::, _>>()?; - 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, - )) -} - -#[cfg(not(target_os = "linux"))] -async fn detect_public_ipv6_prefix_linux() -> Result, Error> { - Ok(None) -} - impl InstanceRpcServerHook { pub fn new(rpc_portal_whitelist: Option>) -> Self { let rpc_portal_whitelist = rpc_portal_whitelist @@ -868,57 +668,8 @@ impl Instance { } async fn prepare_public_ipv6_config(&self) -> Result<(), Error> { - if self.global_ctx.config.get_ipv6_public_addr_auto() - && self.global_ctx.get_ipv6().is_some() - { - return Err(anyhow::anyhow!( - "cannot use --ipv6-public-addr-auto together with a manually set --ipv6; pick one or the other" - ) - .into()); - } - - if !self.global_ctx.config.get_ipv6_public_addr_provider() { - let mut feature_flags = self.global_ctx.get_feature_flags(); - feature_flags.ipv6_public_addr_provider = false; - self.global_ctx.set_feature_flags(feature_flags); - return Ok(()); - } - - ensure_public_ipv6_provider_supported()?; - - let prefix = if let Some(prefix) = self.global_ctx.config.get_ipv6_public_addr_prefix() { - prefix - } else { - let _g = self.global_ctx.net_ns.guard(); - detect_public_ipv6_prefix_linux() - .await? - .ok_or_else(public_ipv6_provider_auto_detect_error)? - }; - - if !is_global_routable_public_ipv6_prefix(prefix) { - return Err(anyhow::anyhow!( - "the prefix {} is not a valid global unicast IPv6 prefix; it must be a routable address range, not a private, link-local, or multicast address", - prefix - ) - .into()); - } - - #[cfg(target_os = "linux")] - { - let _g = self.global_ctx.net_ns.guard(); - if ensure_linux_ipv6_forwarding()? { - tracing::info!( - "enabled Linux IPv6 forwarding for public IPv6 provider at runtime; this change is not persisted across reboot" - ); - } - } - - self.global_ctx - .config - .set_ipv6_public_addr_prefix(Some(prefix)); - let mut feature_flags = self.global_ctx.get_feature_flags(); - feature_flags.ipv6_public_addr_provider = true; - self.global_ctx.set_feature_flags(feature_flags); + validate_public_ipv6_config(&self.global_ctx)?; + reconcile_public_ipv6_provider_runtime(&self.global_ctx).await; Ok(()) } @@ -1198,6 +949,7 @@ impl Instance { .await?; self.listener_manager.lock().await.run().await?; self.peer_manager.run().await?; + run_public_ipv6_provider_reconcile_task(&self.global_ctx); #[cfg(feature = "tun")] { @@ -1802,88 +1554,10 @@ impl Drop for Instance { #[cfg(test)] mod tests { - #[cfg(target_os = "linux")] - use netlink_packet_route::route::RouteType; - #[cfg(target_os = "linux")] - use std::fs; - #[cfg(target_os = "linux")] - use std::path::PathBuf; - #[cfg(target_os = "linux")] - use std::process::Command; - use crate::{ instance::instance::InstanceRpcServerHook, proto::rpc_impl::standalone::RpcServerHook, }; - #[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, - }; - - #[cfg(not(target_os = "linux"))] - use super::{ensure_public_ipv6_provider_supported, public_ipv6_provider_auto_detect_error}; - - #[cfg(target_os = "linux")] - fn run_ip(args: &[&str]) { - let output = Command::new("ip") - .args(args) - .output() - .expect("failed to execute ip process"); - assert!( - output.status.success(), - "ip command failed: {:?}\nstdout: {}\nstderr: {}", - args, - String::from_utf8_lossy(&output.stdout), - String::from_utf8_lossy(&output.stderr), - ); - } - - #[cfg(target_os = "linux")] - fn test_iface_name(tag: &str) -> String { - format!("et{}{:x}", tag, std::process::id() & 0xffff) - } - - #[cfg(target_os = "linux")] - struct ScopedDummyLink { - name: String, - } - - #[cfg(target_os = "linux")] - impl ScopedDummyLink { - fn new(name: &str) -> Self { - let _ = Command::new("ip").args(["link", "del", name]).output(); - run_ip(&["link", "add", name, "type", "dummy"]); - run_ip(&["link", "set", name, "up"]); - Self { - name: name.to_string(), - } - } - } - - #[cfg(target_os = "linux")] - impl Drop for ScopedDummyLink { - fn drop(&mut self) { - let _ = Command::new("ip") - .args(["link", "del", &self.name]) - .output(); - } - } - - #[cfg(target_os = "linux")] - fn temp_forwarding_paths( - all_value: &str, - default_value: &str, - ) -> (tempfile::TempDir, PathBuf, PathBuf) { - let dir = tempfile::tempdir().unwrap(); - let all_path = dir.path().join("all_forwarding"); - let default_path = dir.path().join("default_forwarding"); - fs::write(&all_path, all_value).unwrap(); - fs::write(&default_path, default_value).unwrap(); - (dir, all_path, default_path) - } - #[tokio::test] async fn test_rpc_portal_whitelist() { use cidr::IpCidr; @@ -2002,237 +1676,4 @@ mod tests { } } } - - #[cfg(target_os = "linux")] - fn route( - dst: Option<&str>, - src: Option<&str>, - ifindex: Option, - kind: RouteType, - ) -> DetectedIpv6Route { - DetectedIpv6Route { - dst: dst.map(|cidr| cidr.parse().unwrap()), - src: src.map(|cidr| cidr.parse().unwrap()), - ifindex, - kind, - } - } - - #[cfg(target_os = "linux")] - #[test] - fn test_detect_public_ipv6_prefix_from_routes_selects_delegated_prefix() { - let routes = vec![ - route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), - route(Some("2001:db8:1::/56"), None, Some(3), RouteType::Unicast), - ]; - - assert_eq!( - detect_public_ipv6_prefix_from_routes(&routes, 1), - Some("2001:db8:1::/56".parse().unwrap()) - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_detect_public_ipv6_prefix_from_routes_rejects_non_public_prefixes() { - let routes = vec![ - route(Some("::/0"), Some("fd00::/48"), Some(2), RouteType::Unicast), - route(Some("fd00::/48"), None, Some(3), RouteType::Unicast), - route(None, Some("fe80::/64"), Some(4), RouteType::Unicast), - route(Some("fe80::/64"), None, Some(5), RouteType::Unicast), - route(None, Some("ff00::/8"), Some(6), RouteType::Unicast), - route(Some("ff00::/8"), None, Some(7), RouteType::Unicast), - route(None, Some("::/0"), Some(8), RouteType::Unicast), - route(Some("::/0"), None, Some(9), RouteType::Unicast), - ]; - - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_detect_public_ipv6_prefix_from_routes_requires_delegated_route() { - let routes = vec![route( - None, - Some("2001:db8:1::/56"), - Some(2), - RouteType::Unicast, - )]; - - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_detect_public_ipv6_prefix_from_routes_rejects_loopback_delegation() { - let routes = vec![ - route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), - route(Some("2001:db8:1::/56"), None, Some(1), RouteType::Unicast), - ]; - - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_detect_public_ipv6_prefix_from_routes_prefers_shortest_prefix() { - let routes = vec![ - route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), - route(Some("2001:db8:1::/56"), None, Some(3), RouteType::Unicast), - route(None, Some("2001:db8::/48"), Some(4), RouteType::Unicast), - route(Some("2001:db8::/48"), None, Some(5), RouteType::Unicast), - ]; - - assert_eq!( - detect_public_ipv6_prefix_from_routes(&routes, 1), - Some("2001:db8::/48".parse().unwrap()) - ); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_detect_public_ipv6_prefix_from_routes_rejects_non_unicast_delegation() { - let routes = vec![ - route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), - route(Some("2001:db8:1::/56"), None, Some(3), RouteType::BlackHole), - ]; - - assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); - } - - #[test] - fn test_public_ipv6_provider_auto_detect_error_mentions_manual_prefix() { - let err = public_ipv6_provider_auto_detect_error(); - let msg = err.to_string(); - - assert!(msg.contains("IPv6 prefix"), "{}", msg); - assert!(msg.contains("ipv6-public-addr-prefix"), "{}", msg); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_public_ipv6_provider_platform_check_accepts_linux() { - assert!(ensure_public_ipv6_provider_supported().is_ok()); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_ensure_linux_ipv6_forwarding_enables_all_and_default() { - let (_dir, all_path, default_path) = temp_forwarding_paths("0\n", "0\n"); - - let changed = ensure_linux_ipv6_forwarding_at_paths(&all_path, &default_path).unwrap(); - - assert!(changed); - assert_eq!(fs::read_to_string(&all_path).unwrap(), "1\n"); - assert_eq!(fs::read_to_string(&default_path).unwrap(), "1\n"); - } - - #[cfg(target_os = "linux")] - #[test] - fn test_ensure_linux_ipv6_forwarding_is_noop_when_already_enabled() { - let (_dir, all_path, default_path) = temp_forwarding_paths("1\n", "1\n"); - - let changed = ensure_linux_ipv6_forwarding_at_paths(&all_path, &default_path).unwrap(); - - assert!(!changed); - assert_eq!(fs::read_to_string(&all_path).unwrap(), "1\n"); - assert_eq!(fs::read_to_string(&default_path).unwrap(), "1\n"); - } - - #[cfg(not(target_os = "linux"))] - #[test] - fn test_public_ipv6_provider_platform_check_reports_linux_only() { - let err = ensure_public_ipv6_provider_supported().unwrap_err(); - let msg = err.to_string(); - - assert!(msg.contains("Linux"), "{}", msg); - assert!(msg.contains("ipv6-public-addr-auto"), "{}", msg); - } - - #[cfg(target_os = "linux")] - #[serial_test::serial] - #[tokio::test] - async fn test_detect_public_ipv6_prefix_linux_reads_netlink_routes_from_kernel() { - let wan_if = test_iface_name("dw"); - let lan_if = test_iface_name("dl"); - let _wan = ScopedDummyLink::new(&wan_if); - let _lan = ScopedDummyLink::new(&lan_if); - - run_ip(&[ - "-6", - "addr", - "add", - "2001:db8:100:ffff::1/64", - "dev", - &wan_if, - ]); - run_ip(&[ - "-6", - "route", - "add", - "default", - "from", - "2001:db8:100::/56", - "dev", - &wan_if, - ]); - run_ip(&["-6", "route", "add", "2001:db8:100::/56", "dev", &lan_if]); - - assert_eq!( - 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_prefers_shortest_prefix_from_kernel() { - let wan_if_1 = test_iface_name("sw1"); - let lan_if_1 = test_iface_name("sl1"); - let wan_if_2 = test_iface_name("sw2"); - let lan_if_2 = test_iface_name("sl2"); - let _wan_1 = ScopedDummyLink::new(&wan_if_1); - let _lan_1 = ScopedDummyLink::new(&lan_if_1); - let _wan_2 = ScopedDummyLink::new(&wan_if_2); - let _lan_2 = ScopedDummyLink::new(&lan_if_2); - - run_ip(&[ - "-6", - "addr", - "add", - "2001:db8:3000:ffff::1/64", - "dev", - &wan_if_1, - ]); - run_ip(&[ - "-6", - "route", - "add", - "default", - "from", - "2001:db8:3000::/56", - "dev", - &wan_if_1, - ]); - run_ip(&["-6", "route", "add", "2001:db8:3000::/56", "dev", &lan_if_1]); - - run_ip(&["-6", "addr", "add", "2001:db9:ffff::1/64", "dev", &wan_if_2]); - run_ip(&[ - "-6", - "route", - "add", - "default", - "from", - "2001:db9::/48", - "dev", - &wan_if_2, - ]); - run_ip(&["-6", "route", "add", "2001:db9::/48", "dev", &lan_if_2]); - - assert_eq!( - detect_public_ipv6_prefix_linux().await.unwrap(), - Some("2001:db9::/48".parse().unwrap()) - ); - } } diff --git a/easytier/src/instance/mod.rs b/easytier/src/instance/mod.rs index d50ad23e..756341c4 100644 --- a/easytier/src/instance/mod.rs +++ b/easytier/src/instance/mod.rs @@ -4,6 +4,8 @@ pub mod instance; pub mod listeners; +mod public_ipv6_provider; + pub mod proxy_cidrs_monitor; #[cfg(feature = "tun")] diff --git a/easytier/src/instance/public_ipv6_provider.rs b/easytier/src/instance/public_ipv6_provider.rs new file mode 100644 index 00000000..68799db9 --- /dev/null +++ b/easytier/src/instance/public_ipv6_provider.rs @@ -0,0 +1,905 @@ +use std::{path::Path, sync::Arc}; + +use anyhow::Context; +use cidr::Ipv6Cidr; +#[cfg(target_os = "linux")] +use netlink_packet_route::route::{RouteAddress, RouteAttribute, RouteMessage, RouteType}; + +#[cfg(target_os = "linux")] +use crate::common::ifcfg::{get_interface_index, list_ipv6_route_messages}; +use crate::common::{ + error::Error, + global_ctx::{ArcGlobalCtx, GlobalCtxEvent}, +}; + +const PUBLIC_IPV6_PROVIDER_RECONCILE_INTERVAL: std::time::Duration = + std::time::Duration::from_secs(5); +const PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES: usize = 3; + +#[derive(Debug, Clone, PartialEq, Eq)] +enum PublicIpv6ProviderRuntimeState { + Disabled, + Pending(String), + Active(Ipv6Cidr), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct PublicIpv6ProviderConfigSnapshot { + provider_enabled: bool, + configured_prefix: Option, +} + +fn read_public_ipv6_provider_config_snapshot( + global_ctx: &ArcGlobalCtx, +) -> PublicIpv6ProviderConfigSnapshot { + PublicIpv6ProviderConfigSnapshot { + provider_enabled: global_ctx.config.get_ipv6_public_addr_provider(), + configured_prefix: global_ctx.config.get_ipv6_public_addr_prefix(), + } +} + +fn should_run_public_ipv6_provider_reconcile_task( + config: PublicIpv6ProviderConfigSnapshot, +) -> bool { + config.provider_enabled && config.configured_prefix.is_none() +} + +fn is_global_routable_public_ipv6_prefix(prefix: Ipv6Cidr) -> bool { + let addr = prefix.first_address(); + !addr.is_loopback() + && !addr.is_multicast() + && !addr.is_unicast_link_local() + && !addr.is_unique_local() + && !addr.is_unspecified() +} + +pub(super) fn validate_public_ipv6_config(global_ctx: &ArcGlobalCtx) -> Result<(), Error> { + if global_ctx.config.get_ipv6_public_addr_auto() && global_ctx.get_ipv6().is_some() { + return Err(anyhow::anyhow!( + "cannot use --ipv6-public-addr-auto together with a manually set --ipv6; pick one or the other" + ) + .into()); + } + + if !global_ctx.config.get_ipv6_public_addr_provider() { + return Ok(()); + } + + ensure_public_ipv6_provider_supported()?; + + if let Some(prefix) = global_ctx.config.get_ipv6_public_addr_prefix() + && !is_global_routable_public_ipv6_prefix(prefix) + { + return Err(anyhow::anyhow!( + "the prefix {} is not a valid global unicast IPv6 prefix; it must be a routable address range, not a private, link-local, or multicast address", + prefix + ) + .into()); + } + + Ok(()) +} + +fn ensure_public_ipv6_provider_supported() -> Result<(), Error> { + if cfg!(target_os = "linux") { + return Ok(()); + } + + Err(anyhow::anyhow!( + "the provider feature requires Linux; run without --ipv6-public-addr-provider on this node, or move the provider role to a Linux node. client mode (--ipv6-public-addr-auto) works on all platforms" + ) + .into()) +} + +fn public_ipv6_provider_auto_detect_error() -> Error { + anyhow::anyhow!( + "no public IPv6 prefix found on this system; set --ipv6-public-addr-prefix manually, or check that your ISP has delegated an IPv6 prefix and a default-from route exists in the kernel routing table" + ) + .into() +} + +#[cfg(target_os = "linux")] +fn read_linux_proc_bool(path: &Path) -> Result { + let value = std::fs::read_to_string(path) + .with_context(|| format!("failed to read {}", path.display()))?; + match value.trim() { + "0" => Ok(false), + "1" => Ok(true), + other => Err(anyhow::anyhow!("unexpected value '{}' in {}", other, path.display()).into()), + } +} + +#[cfg(target_os = "linux")] +fn write_linux_proc_bool(path: &Path, enabled: bool) -> Result<(), Error> { + let value = if enabled { "1\n" } else { "0\n" }; + std::fs::write(path, value).with_context(|| format!("failed to write {}", path.display()))?; + Ok(()) +} + +#[cfg(target_os = "linux")] +fn ensure_linux_ipv6_forwarding_at_paths( + all_path: &Path, + default_path: &Path, +) -> Result { + let all_enabled = read_linux_proc_bool(all_path)?; + let default_enabled = read_linux_proc_bool(default_path)?; + let mut changed = false; + + if !all_enabled { + write_linux_proc_bool(all_path, true)?; + changed = true; + } + + if !default_enabled { + write_linux_proc_bool(default_path, true)?; + changed = true; + } + + if !read_linux_proc_bool(all_path)? || !read_linux_proc_bool(default_path)? { + return Err(anyhow::anyhow!( + "failed to enable Linux IPv6 forwarding in {} and {}", + all_path.display(), + default_path.display() + ) + .into()); + } + + Ok(changed) +} + +#[cfg(target_os = "linux")] +fn ensure_linux_ipv6_forwarding() -> Result { + let all_path = Path::new("/proc/sys/net/ipv6/conf/all/forwarding"); + let default_path = Path::new("/proc/sys/net/ipv6/conf/default/forwarding"); + + ensure_linux_ipv6_forwarding_at_paths(all_path, default_path).map_err(|err| { + anyhow::anyhow!( + "public IPv6 provider requires Linux IPv6 forwarding; failed to enable net.ipv6.conf.all.forwarding=1 and net.ipv6.conf.default.forwarding=1 automatically: {}. run with sufficient privileges or set them manually", + err + ) + .into() + }) +} + +#[cfg(target_os = "linux")] +#[derive(Clone, Debug, PartialEq, Eq)] +struct DetectedIpv6Route { + dst: Option, + src: Option, + ifindex: Option, + kind: RouteType, +} + +#[cfg(target_os = "linux")] +fn ipv6_cidr_from_route_addr(addr: RouteAddress, prefix_len: u8) -> Option { + match addr { + RouteAddress::Inet6(addr) => Ipv6Cidr::new(addr, prefix_len).ok(), + _ => None, + } +} + +#[cfg(target_os = "linux")] +impl TryFrom for DetectedIpv6Route { + type Error = Error; + + fn try_from(message: RouteMessage) -> Result { + let dst = message.attributes.iter().find_map(|attr| match attr { + RouteAttribute::Destination(addr) => { + ipv6_cidr_from_route_addr(addr.clone(), message.header.destination_prefix_length) + } + _ => None, + }); + let src = message.attributes.iter().find_map(|attr| match attr { + RouteAttribute::Source(addr) => { + ipv6_cidr_from_route_addr(addr.clone(), message.header.source_prefix_length) + } + _ => None, + }); + let ifindex = message.attributes.iter().find_map(|attr| match attr { + RouteAttribute::Oif(index) => Some(*index), + _ => None, + }); + + Ok(Self { + dst, + src, + ifindex, + kind: message.header.kind, + }) + } +} + +#[cfg(target_os = "linux")] +fn is_ipv6_default_route(dst: Option) -> bool { + dst.is_none() || dst == Some(Ipv6Cidr::new(std::net::Ipv6Addr::UNSPECIFIED, 0).unwrap()) +} + +#[cfg(target_os = "linux")] +fn detect_public_ipv6_prefix_from_routes( + routes: &[DetectedIpv6Route], + loopback_ifindex: u32, +) -> Option { + routes + .iter() + .filter_map(|route| { + if !is_ipv6_default_route(route.dst) { + return None; + } + + let prefix = route.src?; + let wan_ifindex = route.ifindex?; + if !is_global_routable_public_ipv6_prefix(prefix) { + return None; + } + + let delegated = routes.iter().any(|candidate| { + candidate.dst == Some(prefix) + && candidate.ifindex.is_some() + && candidate.ifindex != Some(wan_ifindex) + && candidate.ifindex != Some(loopback_ifindex) + && candidate.kind == RouteType::Unicast + }); + + delegated.then_some(prefix) + }) + .min_by_key(|prefix| prefix.network_length()) +} + +#[cfg(target_os = "linux")] +async fn detect_public_ipv6_prefix_linux() -> Result, Error> { + let routes = list_ipv6_route_messages().with_context(|| "failed to query linux ipv6 routes")?; + let routes = routes + .iter() + .cloned() + .map(DetectedIpv6Route::try_from) + .collect::, _>>()?; + 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, + )) +} + +#[cfg(not(target_os = "linux"))] +async fn detect_public_ipv6_prefix_linux() -> Result, Error> { + Ok(None) +} + +fn invalid_public_ipv6_prefix_state( + prefix: Ipv6Cidr, + source: &str, +) -> PublicIpv6ProviderRuntimeState { + PublicIpv6ProviderRuntimeState::Pending(format!( + "the {} prefix {} is not a valid global unicast IPv6 prefix", + source, prefix + )) +} + +#[cfg(target_os = "linux")] +async fn resolve_public_ipv6_provider_runtime_state_linux( + global_ctx: &ArcGlobalCtx, + configured_prefix: Option, +) -> PublicIpv6ProviderRuntimeState { + let _g = global_ctx.net_ns.guard(); + + if let Err(err) = ensure_linux_ipv6_forwarding() { + return PublicIpv6ProviderRuntimeState::Pending(err.to_string()); + } + + if let Some(prefix) = configured_prefix { + if !is_global_routable_public_ipv6_prefix(prefix) { + return invalid_public_ipv6_prefix_state(prefix, "configured"); + } + return PublicIpv6ProviderRuntimeState::Active(prefix); + } + + match detect_public_ipv6_prefix_linux().await { + Ok(Some(prefix)) if is_global_routable_public_ipv6_prefix(prefix) => { + PublicIpv6ProviderRuntimeState::Active(prefix) + } + Ok(Some(prefix)) => invalid_public_ipv6_prefix_state(prefix, "detected"), + Ok(None) => PublicIpv6ProviderRuntimeState::Pending( + public_ipv6_provider_auto_detect_error().to_string(), + ), + Err(err) => PublicIpv6ProviderRuntimeState::Pending(err.to_string()), + } +} + +async fn resolve_public_ipv6_provider_runtime_state( + global_ctx: &ArcGlobalCtx, + config: PublicIpv6ProviderConfigSnapshot, +) -> PublicIpv6ProviderRuntimeState { + if !config.provider_enabled { + return PublicIpv6ProviderRuntimeState::Disabled; + } + + #[cfg(target_os = "linux")] + { + return resolve_public_ipv6_provider_runtime_state_linux( + global_ctx, + config.configured_prefix, + ) + .await; + } + + #[cfg(not(target_os = "linux"))] + { + let _ = config.configured_prefix; + PublicIpv6ProviderRuntimeState::Pending( + ensure_public_ipv6_provider_supported() + .unwrap_err() + .to_string(), + ) + } +} + +fn apply_public_ipv6_provider_runtime_state( + global_ctx: &ArcGlobalCtx, + state: &PublicIpv6ProviderRuntimeState, +) -> bool { + let next_prefix = match state { + PublicIpv6ProviderRuntimeState::Active(prefix) => Some(*prefix), + PublicIpv6ProviderRuntimeState::Disabled | PublicIpv6ProviderRuntimeState::Pending(_) => { + None + } + }; + let prefix_changed = global_ctx.set_advertised_ipv6_public_addr_prefix(next_prefix); + + let next_provider_enabled = matches!(state, PublicIpv6ProviderRuntimeState::Active(_)); + let feature_changed = { + let mut feature_flags = global_ctx.get_feature_flags(); + if feature_flags.ipv6_public_addr_provider == next_provider_enabled { + false + } else { + feature_flags.ipv6_public_addr_provider = next_provider_enabled; + global_ctx.set_feature_flags(feature_flags); + true + } + }; + + prefix_changed || feature_changed +} + +fn try_apply_public_ipv6_provider_runtime_state( + global_ctx: &ArcGlobalCtx, + config: PublicIpv6ProviderConfigSnapshot, + state: &PublicIpv6ProviderRuntimeState, +) -> Option { + (read_public_ipv6_provider_config_snapshot(global_ctx) == config) + .then(|| apply_public_ipv6_provider_runtime_state(global_ctx, state)) +} + +fn current_public_ipv6_provider_runtime_state( + global_ctx: &ArcGlobalCtx, +) -> PublicIpv6ProviderRuntimeState { + match ( + global_ctx.get_feature_flags().ipv6_public_addr_provider, + global_ctx.get_advertised_ipv6_public_addr_prefix(), + ) { + (false, _) => PublicIpv6ProviderRuntimeState::Disabled, + (true, Some(prefix)) => PublicIpv6ProviderRuntimeState::Active(prefix), + (true, None) => PublicIpv6ProviderRuntimeState::Pending( + "public IPv6 provider runtime is missing an advertised prefix".to_string(), + ), + } +} + +async fn reconcile_public_ipv6_provider_runtime_with_state( + global_ctx: &ArcGlobalCtx, +) -> (PublicIpv6ProviderRuntimeState, bool) { + for attempt in 0..PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES { + let config = read_public_ipv6_provider_config_snapshot(global_ctx); + let next_state = resolve_public_ipv6_provider_runtime_state(global_ctx, config).await; + + if let Some(changed) = + try_apply_public_ipv6_provider_runtime_state(global_ctx, config, &next_state) + { + return (next_state, changed); + } + + tracing::debug!( + attempt = attempt + 1, + max_retries = PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES, + "public IPv6 provider config changed during reconcile, retrying" + ); + } + + tracing::warn!( + max_retries = PUBLIC_IPV6_PROVIDER_RECONCILE_MAX_RETRIES, + "skipping public IPv6 provider reconcile because config kept changing" + ); + ( + current_public_ipv6_provider_runtime_state(global_ctx), + false, + ) +} + +pub(super) async fn reconcile_public_ipv6_provider_runtime(global_ctx: &ArcGlobalCtx) -> bool { + reconcile_public_ipv6_provider_runtime_with_state(global_ctx) + .await + .1 +} + +pub(super) fn run_public_ipv6_provider_reconcile_task(global_ctx: &ArcGlobalCtx) { + if !should_run_public_ipv6_provider_reconcile_task(read_public_ipv6_provider_config_snapshot( + global_ctx, + )) { + return; + } + + let global_ctx = Arc::downgrade(global_ctx); + tokio::spawn(async move { + let Some(initial_ctx) = global_ctx.upgrade() else { + return; + }; + let mut event_receiver = initial_ctx.subscribe(); + let mut last_state: Option = None; + + loop { + let Some(global_ctx) = global_ctx.upgrade() else { + tracing::debug!("global ctx dropped, stopping public ipv6 provider reconcile"); + return; + }; + + 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"); + } + 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) => {} + } + } + } + }); +} + +#[cfg(test)] +mod tests { + #[cfg(target_os = "linux")] + use std::fs; + #[cfg(target_os = "linux")] + use std::path::PathBuf; + #[cfg(target_os = "linux")] + use std::process::Command; + use std::sync::Arc; + + #[cfg(target_os = "linux")] + use netlink_packet_route::route::RouteType; + + #[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, + }; + + use super::{ + PublicIpv6ProviderConfigSnapshot, PublicIpv6ProviderRuntimeState, + 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, + }; + + #[cfg(target_os = "linux")] + fn run_ip(args: &[&str]) { + let output = Command::new("ip") + .args(args) + .output() + .expect("failed to execute ip process"); + assert!( + output.status.success(), + "ip command failed: {:?}\nstdout: {}\nstderr: {}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + } + + #[cfg(target_os = "linux")] + fn test_iface_name(tag: &str) -> String { + format!("et{}{:x}", tag, std::process::id() & 0xffff) + } + + #[cfg(target_os = "linux")] + struct ScopedDummyLink { + name: String, + } + + #[cfg(target_os = "linux")] + impl ScopedDummyLink { + fn new(name: &str) -> Self { + let _ = Command::new("ip").args(["link", "del", name]).output(); + run_ip(&["link", "add", name, "type", "dummy"]); + run_ip(&["link", "set", name, "up"]); + Self { + name: name.to_string(), + } + } + } + + #[cfg(target_os = "linux")] + impl Drop for ScopedDummyLink { + fn drop(&mut self) { + let _ = Command::new("ip") + .args(["link", "del", &self.name]) + .output(); + } + } + + #[cfg(target_os = "linux")] + fn temp_forwarding_paths( + all_value: &str, + default_value: &str, + ) -> (tempfile::TempDir, PathBuf, PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let all_path = dir.path().join("all_forwarding"); + let default_path = dir.path().join("default_forwarding"); + fs::write(&all_path, all_value).unwrap(); + fs::write(&default_path, default_value).unwrap(); + (dir, all_path, default_path) + } + + #[cfg(target_os = "linux")] + fn route( + dst: Option<&str>, + src: Option<&str>, + ifindex: Option, + kind: RouteType, + ) -> DetectedIpv6Route { + DetectedIpv6Route { + dst: dst.map(|cidr| cidr.parse().unwrap()), + src: src.map(|cidr| cidr.parse().unwrap()), + ifindex, + kind, + } + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_public_ipv6_prefix_from_routes_selects_delegated_prefix() { + let routes = vec![ + route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), + route(Some("2001:db8:1::/56"), None, Some(3), RouteType::Unicast), + ]; + + assert_eq!( + detect_public_ipv6_prefix_from_routes(&routes, 1), + Some("2001:db8:1::/56".parse().unwrap()) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_public_ipv6_prefix_from_routes_rejects_non_public_prefixes() { + let routes = vec![ + route(Some("::/0"), Some("fd00::/48"), Some(2), RouteType::Unicast), + route(Some("fd00::/48"), None, Some(3), RouteType::Unicast), + route(None, Some("fe80::/64"), Some(4), RouteType::Unicast), + route(Some("fe80::/64"), None, Some(5), RouteType::Unicast), + route(None, Some("ff00::/8"), Some(6), RouteType::Unicast), + route(Some("ff00::/8"), None, Some(7), RouteType::Unicast), + route(None, Some("::/0"), Some(8), RouteType::Unicast), + route(Some("::/0"), None, Some(9), RouteType::Unicast), + ]; + + assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_public_ipv6_prefix_from_routes_requires_delegated_route() { + let routes = vec![route( + None, + Some("2001:db8:1::/56"), + Some(2), + RouteType::Unicast, + )]; + + assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_public_ipv6_prefix_from_routes_rejects_loopback_delegation() { + let routes = vec![ + route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), + route(Some("2001:db8:1::/56"), None, Some(1), RouteType::Unicast), + ]; + + assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_public_ipv6_prefix_from_routes_prefers_shortest_prefix() { + let routes = vec![ + route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), + route(Some("2001:db8:1::/56"), None, Some(3), RouteType::Unicast), + route(None, Some("2001:db8::/48"), Some(4), RouteType::Unicast), + route(Some("2001:db8::/48"), None, Some(5), RouteType::Unicast), + ]; + + assert_eq!( + detect_public_ipv6_prefix_from_routes(&routes, 1), + Some("2001:db8::/48".parse().unwrap()) + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_detect_public_ipv6_prefix_from_routes_rejects_non_unicast_delegation() { + let routes = vec![ + route(None, Some("2001:db8:1::/56"), Some(2), RouteType::Unicast), + route(Some("2001:db8:1::/56"), None, Some(3), RouteType::BlackHole), + ]; + + assert_eq!(detect_public_ipv6_prefix_from_routes(&routes, 1), None); + } + + #[test] + fn test_public_ipv6_provider_auto_detect_error_mentions_manual_prefix() { + let err = public_ipv6_provider_auto_detect_error(); + let msg = err.to_string(); + + assert!(msg.contains("IPv6 prefix"), "{}", msg); + assert!(msg.contains("ipv6-public-addr-prefix"), "{}", msg); + } + + fn test_global_ctx() -> Arc { + Arc::new(GlobalCtx::new(TomlConfigLoader::default())) + } + + #[tokio::test] + async fn test_read_public_ipv6_provider_config_snapshot_reads_provider_fields() { + let global_ctx = test_global_ctx(); + let prefix = "2001:db8::/48".parse().unwrap(); + global_ctx.config.set_ipv6_public_addr_provider(true); + global_ctx.config.set_ipv6_public_addr_prefix(Some(prefix)); + + assert_eq!( + read_public_ipv6_provider_config_snapshot(&global_ctx), + PublicIpv6ProviderConfigSnapshot { + provider_enabled: true, + configured_prefix: Some(prefix), + } + ); + } + + #[test] + fn test_reconcile_task_only_runs_for_auto_detect_provider() { + assert!(!should_run_public_ipv6_provider_reconcile_task( + PublicIpv6ProviderConfigSnapshot { + provider_enabled: false, + configured_prefix: None, + } + )); + assert!(!should_run_public_ipv6_provider_reconcile_task( + PublicIpv6ProviderConfigSnapshot { + provider_enabled: true, + configured_prefix: Some("2001:db8::/48".parse().unwrap()), + } + )); + assert!(should_run_public_ipv6_provider_reconcile_task( + PublicIpv6ProviderConfigSnapshot { + provider_enabled: true, + configured_prefix: None, + } + )); + } + + #[tokio::test] + async fn test_try_apply_public_ipv6_provider_runtime_state_rejects_stale_config() { + let global_ctx = test_global_ctx(); + let prefix = "2001:db8::/48".parse().unwrap(); + let config = PublicIpv6ProviderConfigSnapshot { + provider_enabled: true, + configured_prefix: Some(prefix), + }; + + global_ctx.config.set_ipv6_public_addr_provider(false); + global_ctx.config.set_ipv6_public_addr_prefix(None); + + let changed = try_apply_public_ipv6_provider_runtime_state( + &global_ctx, + config, + &PublicIpv6ProviderRuntimeState::Active(prefix), + ); + + assert_eq!(changed, None); + assert_eq!(global_ctx.get_advertised_ipv6_public_addr_prefix(), None); + assert!(!global_ctx.get_feature_flags().ipv6_public_addr_provider); + } + + #[tokio::test] + async fn test_try_apply_public_ipv6_provider_runtime_state_applies_matching_config() { + let global_ctx = test_global_ctx(); + let prefix = "2001:db8::/48".parse().unwrap(); + global_ctx.config.set_ipv6_public_addr_provider(true); + global_ctx.config.set_ipv6_public_addr_prefix(Some(prefix)); + let config = read_public_ipv6_provider_config_snapshot(&global_ctx); + + let changed = try_apply_public_ipv6_provider_runtime_state( + &global_ctx, + config, + &PublicIpv6ProviderRuntimeState::Active(prefix), + ); + + assert_eq!(changed, Some(true)); + assert_eq!( + global_ctx.get_advertised_ipv6_public_addr_prefix(), + Some(prefix) + ); + assert!(global_ctx.get_feature_flags().ipv6_public_addr_provider); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_public_ipv6_provider_platform_check_accepts_linux() { + assert!(ensure_public_ipv6_provider_supported().is_ok()); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_ensure_linux_ipv6_forwarding_enables_all_and_default() { + let (_dir, all_path, default_path) = temp_forwarding_paths("0\n", "0\n"); + + let changed = ensure_linux_ipv6_forwarding_at_paths(&all_path, &default_path).unwrap(); + + assert!(changed); + assert_eq!(fs::read_to_string(&all_path).unwrap(), "1\n"); + assert_eq!(fs::read_to_string(&default_path).unwrap(), "1\n"); + } + + #[cfg(target_os = "linux")] + #[test] + fn test_ensure_linux_ipv6_forwarding_is_noop_when_already_enabled() { + let (_dir, all_path, default_path) = temp_forwarding_paths("1\n", "1\n"); + + let changed = ensure_linux_ipv6_forwarding_at_paths(&all_path, &default_path).unwrap(); + + assert!(!changed); + assert_eq!(fs::read_to_string(&all_path).unwrap(), "1\n"); + assert_eq!(fs::read_to_string(&default_path).unwrap(), "1\n"); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn test_public_ipv6_provider_platform_check_reports_linux_only() { + let err = ensure_public_ipv6_provider_supported().unwrap_err(); + let msg = err.to_string(); + + assert!(msg.contains("Linux"), "{}", msg); + assert!(msg.contains("ipv6-public-addr-auto"), "{}", msg); + } + + #[cfg(target_os = "linux")] + #[serial_test::serial] + #[tokio::test] + async fn test_detect_public_ipv6_prefix_linux_reads_netlink_routes_from_kernel() { + let wan_if = test_iface_name("dw"); + let lan_if = test_iface_name("dl"); + let _wan = ScopedDummyLink::new(&wan_if); + let _lan = ScopedDummyLink::new(&lan_if); + + run_ip(&[ + "-6", + "addr", + "add", + "2001:db8:100:ffff::1/64", + "dev", + &wan_if, + ]); + run_ip(&[ + "-6", + "route", + "add", + "default", + "from", + "2001:db8:100::/56", + "dev", + &wan_if, + ]); + run_ip(&["-6", "route", "add", "2001:db8:100::/56", "dev", &lan_if]); + + assert_eq!( + 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_prefers_shortest_prefix_from_kernel() { + let wan_if_1 = test_iface_name("sw1"); + let lan_if_1 = test_iface_name("sl1"); + let wan_if_2 = test_iface_name("sw2"); + let lan_if_2 = test_iface_name("sl2"); + let _wan_1 = ScopedDummyLink::new(&wan_if_1); + let _lan_1 = ScopedDummyLink::new(&lan_if_1); + let _wan_2 = ScopedDummyLink::new(&wan_if_2); + let _lan_2 = ScopedDummyLink::new(&lan_if_2); + + run_ip(&[ + "-6", + "addr", + "add", + "2001:db8:3000:ffff::1/64", + "dev", + &wan_if_1, + ]); + run_ip(&[ + "-6", + "route", + "add", + "default", + "from", + "2001:db8:3000::/56", + "dev", + &wan_if_1, + ]); + run_ip(&["-6", "route", "add", "2001:db8:3000::/56", "dev", &lan_if_1]); + + run_ip(&["-6", "addr", "add", "2001:db9:ffff::1/64", "dev", &wan_if_2]); + run_ip(&[ + "-6", + "route", + "add", + "default", + "from", + "2001:db9::/48", + "dev", + &wan_if_2, + ]); + run_ip(&["-6", "route", "add", "2001:db9::/48", "dev", &lan_if_2]); + + assert_eq!( + detect_public_ipv6_prefix_linux().await.unwrap(), + Some("2001:db9::/48".parse().unwrap()) + ); + } +} diff --git a/easytier/src/peers/peer_manager.rs b/easytier/src/peers/peer_manager.rs index 95d10e4b..98b28b48 100644 --- a/easytier/src/peers/peer_manager.rs +++ b/easytier/src/peers/peer_manager.rs @@ -1888,6 +1888,14 @@ impl PeerManager { feature_flag: Some(self.global_ctx.get_feature_flags()), ip_list: Some(self.global_ctx.get_ip_collector().collect_ip_addrs().await), public_ipv6_addr: self.get_my_public_ipv6_addr().await.map(Into::into), + ipv6_public_addr_prefix: self + .global_ctx + .get_advertised_ipv6_public_addr_prefix() + .map(|prefix| { + cidr::Ipv6Inet::new(prefix.first_address(), prefix.network_length()) + .unwrap() + .into() + }), } } diff --git a/easytier/src/peers/peer_ospf_route.rs b/easytier/src/peers/peer_ospf_route.rs index fb9a1405..5d5b6685 100644 --- a/easytier/src/peers/peer_ospf_route.rs +++ b/easytier/src/peers/peer_ospf_route.rs @@ -270,7 +270,7 @@ impl RoutePeerInfo { .unwrap_or(24), ipv6_addr: global_ctx.get_ipv6().map(|x| x.into()), - ipv6_public_addr_prefix: global_ctx.config.get_ipv6_public_addr_prefix().map( + ipv6_public_addr_prefix: global_ctx.get_advertised_ipv6_public_addr_prefix().map( |prefix| { Ipv6Inet::new(prefix.first_address(), prefix.network_length()) .unwrap() diff --git a/easytier/src/proto/api_instance.proto b/easytier/src/proto/api_instance.proto index c64f4e5f..122f168b 100644 --- a/easytier/src/proto/api_instance.proto +++ b/easytier/src/proto/api_instance.proto @@ -102,6 +102,7 @@ message NodeInfo { common.PeerFeatureFlag feature_flag = 10; peer_rpc.GetIpListResponse ip_list = 11; common.Ipv6Inet public_ipv6_addr = 12; + common.Ipv6Inet ipv6_public_addr_prefix = 13; } message ShowNodeInfoRequest { InstanceIdentifier instance = 1; } diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index 6cafd6d7..082c6f9e 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -766,8 +766,7 @@ pub async fn public_ipv6_auto_addr_end_to_end() { || async { provider .get_global_ctx() - .config - .get_ipv6_public_addr_prefix() + .get_advertised_ipv6_public_addr_prefix() == Some(PublicIpv6Lab::PROVIDER_PREFIX.parse().unwrap()) }, Duration::from_secs(10), @@ -782,8 +781,32 @@ pub async fn public_ipv6_auto_addr_end_to_end() { .get_global_ctx() .config .get_ipv6_public_addr_prefix(), + None + ); + assert_eq!( + provider + .get_global_ctx() + .get_advertised_ipv6_public_addr_prefix(), Some(PublicIpv6Lab::PROVIDER_PREFIX.parse().unwrap()) ); + let provider_prefix = PublicIpv6Lab::PROVIDER_PREFIX + .parse::() + .unwrap(); + assert_eq!( + provider + .get_peer_manager() + .get_my_info() + .await + .ipv6_public_addr_prefix, + Some( + cidr::Ipv6Inet::new( + provider_prefix.first_address(), + provider_prefix.network_length() + ) + .unwrap() + .into() + ) + ); assert!( leased.address().segments()[0] & 0xfe00 != 0xfc00, "leased address should not be unique-local: {leased}"