mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-05-13 17:35:37 +00:00
prevent EasyTier-managed IPv6 from being used as underlay connections (#2181)
When a node has public IPv6 addresses allocated by EasyTier, those addresses are installed on the host's network interfaces. The system would then pick them up as candidate source/destination addresses for underlay connections (direct peer, UDP hole punch, bind addresses), causing overlay traffic to loop back into the overlay itself. Add a central predicate is_ip_easytier_managed_ipv6() and apply it at every point where IPv6 addresses are selected for underlay use: - Filter managed IPv6 from DNS-resolved connector addresses, including a UDP socket getsockname check to detect whether the OS would route through the overlay to reach a destination - Skip managed IPv6 in bind address selection and STUN candidate filtering - Strip managed IPv6 from GetIpListResponse RPC so peers never learn them - Pass pre-resolved addresses to tunnel connectors to avoid re-resolution Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -64,6 +64,24 @@ async fn resolve_mapped_listener_addrs(listener: &url::Url) -> Result<Vec<Socket
|
||||
socket_addrs(listener, || mapped_listener_port(listener)).await
|
||||
}
|
||||
|
||||
fn is_usable_public_ipv6_candidate(ip: &Ipv6Addr, global_ctx: &ArcGlobalCtx) -> bool {
|
||||
is_usable_public_ipv6_candidate_with_mode(ip, global_ctx, TESTING.load(Ordering::Relaxed))
|
||||
}
|
||||
|
||||
fn is_usable_public_ipv6_candidate_with_mode(
|
||||
ip: &Ipv6Addr,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
testing: bool,
|
||||
) -> bool {
|
||||
!global_ctx.is_ip_easytier_managed_ipv6(ip)
|
||||
&& (testing
|
||||
|| (!ip.is_loopback()
|
||||
&& !ip.is_unspecified()
|
||||
&& !ip.is_unique_local()
|
||||
&& !ip.is_unicast_link_local()
|
||||
&& !ip.is_multicast()))
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
pub trait PeerManagerForDirectConnector {
|
||||
async fn list_peers(&self) -> Vec<PeerId>;
|
||||
@@ -190,34 +208,28 @@ impl DirectConnectorManagerData {
|
||||
.with_context(|| format!("failed to bind local socket for {}", remote_url))?,
|
||||
);
|
||||
let connector_ip = self
|
||||
.peer_manager
|
||||
.get_global_ctx()
|
||||
.global_ctx
|
||||
.get_stun_info_collector()
|
||||
.get_stun_info()
|
||||
.public_ip
|
||||
.iter()
|
||||
.find(|x| x.contains(':'))
|
||||
.ok_or(anyhow::anyhow!(
|
||||
"failed to get public ipv6 address from stun info"
|
||||
))?
|
||||
.parse::<Ipv6Addr>()
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to parse public ipv6 address from stun info: {:?}",
|
||||
self.peer_manager
|
||||
.get_global_ctx()
|
||||
.get_stun_info_collector()
|
||||
.get_stun_info()
|
||||
)
|
||||
})?;
|
||||
let connector_addr =
|
||||
SocketAddr::new(IpAddr::V6(connector_ip), local_socket.local_addr()?.port());
|
||||
.filter_map(|ip| ip.parse::<Ipv6Addr>().ok())
|
||||
.find(|ip| !self.global_ctx.is_ip_easytier_managed_ipv6(ip));
|
||||
|
||||
// ask remote to send v6 hole punch packet
|
||||
// and no matter what the result is, continue to connect
|
||||
let _ = self
|
||||
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url)
|
||||
.await;
|
||||
if let Some(connector_ip) = connector_ip {
|
||||
let connector_addr =
|
||||
SocketAddr::new(IpAddr::V6(connector_ip), local_socket.local_addr()?.port());
|
||||
let _ = self
|
||||
.remote_send_udp_hole_punch_packet(dst_peer_id, connector_addr, remote_url)
|
||||
.await;
|
||||
} else {
|
||||
tracing::debug!(
|
||||
?remote_url,
|
||||
"skip remote IPv6 hole-punch packet; no non-EasyTier public IPv6 in STUN info"
|
||||
);
|
||||
}
|
||||
|
||||
let udp_connector = UdpTunnelConnector::new(remote_url.clone());
|
||||
let remote_addr = SocketAddr::from_url(remote_url.clone(), IpVersion::V6).await?;
|
||||
@@ -479,14 +491,7 @@ impl DirectConnectorManagerData {
|
||||
.iter()
|
||||
.chain(ip_list.public_ipv6.iter())
|
||||
.filter_map(|x| Ipv6Addr::from_str(&x.to_string()).ok())
|
||||
.filter(|x| {
|
||||
TESTING.load(Ordering::Relaxed)
|
||||
|| (!x.is_loopback()
|
||||
&& !x.is_unspecified()
|
||||
&& !x.is_unique_local()
|
||||
&& !x.is_unicast_link_local()
|
||||
&& !x.is_multicast())
|
||||
})
|
||||
.filter(|x| is_usable_public_ipv6_candidate(x, &self.global_ctx))
|
||||
.collect::<HashSet<_>>()
|
||||
.iter()
|
||||
.for_each(|ip| {
|
||||
@@ -515,6 +520,11 @@ impl DirectConnectorManagerData {
|
||||
);
|
||||
}
|
||||
});
|
||||
} else if self.global_ctx.is_ip_easytier_managed_ipv6(s_addr.ip()) {
|
||||
tracing::debug!(
|
||||
?listener,
|
||||
"skip EasyTier-managed IPv6 as direct-connect target"
|
||||
);
|
||||
} else if !s_addr.ip().is_loopback() || TESTING.load(Ordering::Relaxed) {
|
||||
if self
|
||||
.global_ctx
|
||||
@@ -790,9 +800,10 @@ impl DirectConnectorManager {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
use std::{collections::BTreeSet, sync::Arc};
|
||||
|
||||
use crate::{
|
||||
common::global_ctx::tests::get_mock_global_ctx,
|
||||
connector::direct::{
|
||||
DirectConnectorManager, DirectConnectorManagerData, DstListenerUrlBlackListItem,
|
||||
},
|
||||
@@ -809,6 +820,24 @@ mod tests {
|
||||
|
||||
use super::{TESTING, mapped_listener_port, resolve_mapped_listener_addrs};
|
||||
|
||||
#[tokio::test]
|
||||
async fn public_ipv6_candidate_rejects_easytier_managed_addr_even_in_tests() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let managed_ipv6: cidr::Ipv6Inet = "2001:db8::2/128".parse().unwrap();
|
||||
global_ctx.set_public_ipv6_routes(BTreeSet::from([managed_ipv6]));
|
||||
|
||||
assert!(!super::is_usable_public_ipv6_candidate_with_mode(
|
||||
&"2001:db8::2".parse().unwrap(),
|
||||
&global_ctx,
|
||||
true,
|
||||
));
|
||||
assert!(super::is_usable_public_ipv6_candidate_with_mode(
|
||||
&"::1".parse().unwrap(),
|
||||
&global_ctx,
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn udp_ipv6_url_matches_hole_punch_branch_condition() {
|
||||
let remote_url: url::Url = "udp://[2001:db8::1]:11010".parse().unwrap();
|
||||
|
||||
+180
-15
@@ -1,19 +1,17 @@
|
||||
use std::{
|
||||
net::{SocketAddr, SocketAddrV4, SocketAddrV6},
|
||||
sync::Arc,
|
||||
};
|
||||
use std::net::{IpAddr, Ipv6Addr, SocketAddr, SocketAddrV4, SocketAddrV6};
|
||||
|
||||
use crate::{
|
||||
common::{error::Error, global_ctx::ArcGlobalCtx, idn, network::IPCollector},
|
||||
common::{dns::socket_addrs, error::Error, global_ctx::ArcGlobalCtx, idn},
|
||||
connector::dns_connector::DnsTunnelConnector,
|
||||
proto::common::PeerFeatureFlag,
|
||||
tunnel::{
|
||||
self, FromUrl, IpScheme, IpVersion, TunnelConnector, TunnelError, TunnelScheme,
|
||||
self, IpScheme, IpVersion, TunnelConnector, TunnelError, TunnelScheme,
|
||||
ring::RingTunnelConnector, tcp::TcpTunnelConnector, udp::UdpTunnelConnector,
|
||||
},
|
||||
utils::BoxExt,
|
||||
};
|
||||
use http_connector::HttpTunnelConnector;
|
||||
use rand::seq::SliceRandom;
|
||||
|
||||
pub mod direct;
|
||||
pub mod manual;
|
||||
@@ -56,7 +54,7 @@ pub(crate) fn should_background_p2p_with_peer(
|
||||
async fn set_bind_addr_for_peer_connector(
|
||||
connector: &mut (impl TunnelConnector + ?Sized),
|
||||
is_ipv4: bool,
|
||||
ip_collector: &Arc<IPCollector>,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
) {
|
||||
if cfg!(any(
|
||||
target_os = "android",
|
||||
@@ -69,7 +67,7 @@ async fn set_bind_addr_for_peer_connector(
|
||||
return;
|
||||
}
|
||||
|
||||
let ips = ip_collector.collect_ip_addrs().await;
|
||||
let ips = global_ctx.get_ip_collector().collect_ip_addrs().await;
|
||||
if is_ipv4 {
|
||||
let mut bind_addrs = vec![];
|
||||
for ipv4 in ips.interface_ipv4s {
|
||||
@@ -80,7 +78,11 @@ async fn set_bind_addr_for_peer_connector(
|
||||
} else {
|
||||
let mut bind_addrs = vec![];
|
||||
for ipv6 in ips.interface_ipv6s.iter().chain(ips.public_ipv6.iter()) {
|
||||
let socket_addr = SocketAddrV6::new(std::net::Ipv6Addr::from(*ipv6), 0, 0, 0).into();
|
||||
let ipv6 = std::net::Ipv6Addr::from(*ipv6);
|
||||
if global_ctx.is_ip_easytier_managed_ipv6(&ipv6) {
|
||||
continue;
|
||||
}
|
||||
let socket_addr = SocketAddrV6::new(ipv6, 0, 0, 0).into();
|
||||
bind_addrs.push(socket_addr);
|
||||
}
|
||||
connector.set_bind_addrs(bind_addrs);
|
||||
@@ -88,6 +90,144 @@ async fn set_bind_addr_for_peer_connector(
|
||||
let _ = connector;
|
||||
}
|
||||
|
||||
struct ResolvedConnectorAddr {
|
||||
addr: SocketAddr,
|
||||
ip_version: IpVersion,
|
||||
}
|
||||
|
||||
fn connector_default_port(url: &url::Url) -> Option<u16> {
|
||||
url.try_into()
|
||||
.ok()
|
||||
.and_then(|s: TunnelScheme| s.try_into().ok())
|
||||
.map(IpScheme::default_port)
|
||||
}
|
||||
|
||||
fn addr_matches_ip_version(addr: &SocketAddr, ip_version: IpVersion) -> bool {
|
||||
match ip_version {
|
||||
IpVersion::V4 => addr.is_ipv4(),
|
||||
IpVersion::V6 => addr.is_ipv6(),
|
||||
IpVersion::Both => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn infer_effective_ip_version(addrs: &[SocketAddr], requested_ip_version: IpVersion) -> IpVersion {
|
||||
match requested_ip_version {
|
||||
IpVersion::Both if addrs.iter().all(SocketAddr::is_ipv4) => IpVersion::V4,
|
||||
IpVersion::Both if addrs.iter().all(SocketAddr::is_ipv6) => IpVersion::V6,
|
||||
_ => requested_ip_version,
|
||||
}
|
||||
}
|
||||
|
||||
async fn easytier_managed_ipv6_source_for_dst(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
dst_addr: SocketAddrV6,
|
||||
) -> Result<Option<Ipv6Addr>, Error> {
|
||||
let socket = {
|
||||
let _g = global_ctx.net_ns.guard();
|
||||
tokio::net::UdpSocket::bind("[::]:0").await?
|
||||
};
|
||||
socket.connect(SocketAddr::V6(dst_addr)).await?;
|
||||
|
||||
let IpAddr::V6(local_ip) = socket.local_addr()?.ip() else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
Ok(global_ctx
|
||||
.is_ip_easytier_managed_ipv6(&local_ip)
|
||||
.then_some(local_ip))
|
||||
}
|
||||
|
||||
async fn ipv6_connector_reject_reason(
|
||||
url: &url::Url,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
v6_addr: SocketAddrV6,
|
||||
skip_source_validation_errors: bool,
|
||||
) -> Result<Option<String>, Error> {
|
||||
if global_ctx.is_ip_easytier_managed_ipv6(v6_addr.ip()) {
|
||||
return Ok(Some(format!(
|
||||
"{} resolves to EasyTier-managed IPv6 {}",
|
||||
url,
|
||||
v6_addr.ip()
|
||||
)));
|
||||
}
|
||||
|
||||
match easytier_managed_ipv6_source_for_dst(global_ctx, v6_addr).await {
|
||||
Ok(Some(local_ip)) => Ok(Some(format!(
|
||||
"{} would use EasyTier-managed IPv6 {} as local source for {}",
|
||||
url, local_ip, v6_addr
|
||||
))),
|
||||
Ok(None) => Ok(None),
|
||||
Err(err) if skip_source_validation_errors => Ok(Some(format!(
|
||||
"{} IPv6 candidate {} could not be validated: {}",
|
||||
url, v6_addr, err
|
||||
))),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_connector_socket_addr(
|
||||
url: &url::Url,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
ip_version: IpVersion,
|
||||
) -> Result<ResolvedConnectorAddr, Error> {
|
||||
let addrs = socket_addrs(url, || connector_default_port(url))
|
||||
.await
|
||||
.map_err(|e| {
|
||||
TunnelError::InvalidAddr(format!(
|
||||
"failed to resolve socket addr, url: {}, error: {}",
|
||||
url, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut usable_addrs = Vec::new();
|
||||
let mut rejected_ipv6_reason = None;
|
||||
let skip_source_validation_errors = ip_version == IpVersion::Both;
|
||||
for addr in addrs
|
||||
.into_iter()
|
||||
.filter(|addr| addr_matches_ip_version(addr, ip_version))
|
||||
{
|
||||
if let SocketAddr::V6(v6_addr) = addr
|
||||
&& let Some(reason) = ipv6_connector_reject_reason(
|
||||
url,
|
||||
global_ctx,
|
||||
v6_addr,
|
||||
skip_source_validation_errors,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
rejected_ipv6_reason = Some(reason);
|
||||
continue;
|
||||
}
|
||||
|
||||
usable_addrs.push(addr);
|
||||
}
|
||||
|
||||
if usable_addrs.is_empty() {
|
||||
if let Some(reason) = rejected_ipv6_reason {
|
||||
return Err(Error::InvalidUrl(format!(
|
||||
"{}, refusing overlay-backed underlay connection",
|
||||
reason
|
||||
)));
|
||||
}
|
||||
|
||||
return Err(Error::TunnelError(TunnelError::NoDnsRecordFound(
|
||||
ip_version,
|
||||
)));
|
||||
}
|
||||
|
||||
let effective_ip_version = infer_effective_ip_version(&usable_addrs, ip_version);
|
||||
|
||||
let addr = usable_addrs
|
||||
.choose(&mut rand::thread_rng())
|
||||
.copied()
|
||||
.ok_or_else(|| Error::TunnelError(TunnelError::NoDnsRecordFound(ip_version)))?;
|
||||
|
||||
Ok(ResolvedConnectorAddr {
|
||||
addr,
|
||||
ip_version: effective_ip_version,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn create_connector_by_url(
|
||||
url: &str,
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
@@ -98,9 +238,11 @@ pub async fn create_connector_by_url(
|
||||
let scheme = (&url)
|
||||
.try_into()
|
||||
.map_err(|_| TunnelError::InvalidProtocol(url.scheme().to_owned()))?;
|
||||
let mut effective_connector_ip_version = ip_version;
|
||||
let mut connector: Box<dyn TunnelConnector + 'static> = match scheme {
|
||||
TunnelScheme::Ip(scheme) => {
|
||||
let dst_addr = SocketAddr::from_url(url.clone(), ip_version).await?;
|
||||
let resolved_addr = resolve_connector_socket_addr(&url, global_ctx, ip_version).await?;
|
||||
effective_connector_ip_version = resolved_addr.ip_version;
|
||||
let mut connector: Box<dyn TunnelConnector> = match scheme {
|
||||
IpScheme::Tcp => TcpTunnelConnector::new(url).boxed(),
|
||||
IpScheme::Udp => UdpTunnelConnector::new(url).boxed(),
|
||||
@@ -125,11 +267,12 @@ pub async fn create_connector_by_url(
|
||||
#[cfg(feature = "faketcp")]
|
||||
IpScheme::FakeTcp => tunnel::fake_tcp::FakeTcpTunnelConnector::new(url).boxed(),
|
||||
};
|
||||
connector.set_resolved_addr(resolved_addr.addr);
|
||||
if global_ctx.config.get_flags().bind_device {
|
||||
set_bind_addr_for_peer_connector(
|
||||
&mut connector,
|
||||
dst_addr.is_ipv4(),
|
||||
&global_ctx.get_ip_collector(),
|
||||
resolved_addr.addr.is_ipv4(),
|
||||
global_ctx,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -151,16 +294,38 @@ pub async fn create_connector_by_url(
|
||||
DnsTunnelConnector::new(url, global_ctx.clone()).boxed()
|
||||
}
|
||||
};
|
||||
connector.set_ip_version(ip_version);
|
||||
connector.set_ip_version(effective_connector_ip_version);
|
||||
|
||||
Ok(connector)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::proto::common::PeerFeatureFlag;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use super::{should_background_p2p_with_peer, should_try_p2p_with_peer};
|
||||
use crate::{
|
||||
common::global_ctx::tests::get_mock_global_ctx, proto::common::PeerFeatureFlag,
|
||||
tunnel::IpVersion,
|
||||
};
|
||||
|
||||
use super::{
|
||||
create_connector_by_url, should_background_p2p_with_peer, should_try_p2p_with_peer,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn connector_rejects_easytier_managed_ipv6_destination() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let public_route: cidr::Ipv6Inet = "2001:db8::2/128".parse().unwrap();
|
||||
global_ctx.set_public_ipv6_routes(BTreeSet::from([public_route]));
|
||||
|
||||
let ret =
|
||||
create_connector_by_url("tcp://[2001:db8::2]:11010", &global_ctx, IpVersion::V6).await;
|
||||
|
||||
assert!(matches!(
|
||||
ret,
|
||||
Err(crate::common::error::Error::InvalidUrl(_))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lazy_background_p2p_requires_need_p2p() {
|
||||
|
||||
@@ -719,25 +719,31 @@ async fn check_udp_socket_local_addr(
|
||||
) -> Result<(), Error> {
|
||||
let socket = UdpSocket::bind("0.0.0.0:0").await?;
|
||||
socket.connect(remote_mapped_addr).await?;
|
||||
if let Ok(local_addr) = socket.local_addr() {
|
||||
// local_addr should not be equal to an EasyTier-managed virtual/public address.
|
||||
match local_addr.ip() {
|
||||
IpAddr::V4(ip) => {
|
||||
if global_ctx.get_ipv4().map(|ip| ip.address()) == Some(ip) {
|
||||
return Err(anyhow::anyhow!("local address is virtual ipv4").into());
|
||||
}
|
||||
}
|
||||
IpAddr::V6(ip) => {
|
||||
if global_ctx.is_ip_local_ipv6(&ip) {
|
||||
return Err(anyhow::anyhow!("local address is easytier-managed ipv6").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(local_addr) = socket.local_addr()
|
||||
&& let Some(err) = easytier_managed_local_addr_error(&global_ctx, local_addr)
|
||||
{
|
||||
return Err(anyhow::anyhow!(err).into());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn easytier_managed_local_addr_error(
|
||||
global_ctx: &ArcGlobalCtx,
|
||||
local_addr: SocketAddr,
|
||||
) -> Option<&'static str> {
|
||||
// local_addr should not be equal to an EasyTier-managed virtual/public address.
|
||||
match local_addr.ip() {
|
||||
IpAddr::V4(ip) if global_ctx.get_ipv4().map(|ip| ip.address()) == Some(ip) => {
|
||||
Some("local address is virtual ipv4")
|
||||
}
|
||||
IpAddr::V6(ip) if global_ctx.is_ip_easytier_managed_ipv6(&ip) => {
|
||||
Some("local address is easytier-managed ipv6")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn try_connect_with_socket(
|
||||
global_ctx: ArcGlobalCtx,
|
||||
socket: Arc<UdpSocket>,
|
||||
@@ -763,11 +769,29 @@ pub(crate) async fn try_connect_with_socket(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::{collections::BTreeSet, net::SocketAddr};
|
||||
|
||||
use crate::common::global_ctx::tests::get_mock_global_ctx;
|
||||
|
||||
use super::{
|
||||
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, should_create_public_listener,
|
||||
should_retry_public_listener_selection,
|
||||
MAX_PUBLIC_UDP_HOLE_PUNCH_LISTENERS, easytier_managed_local_addr_error,
|
||||
should_create_public_listener, should_retry_public_listener_selection,
|
||||
};
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_addr_check_rejects_easytier_public_ipv6_route() {
|
||||
let global_ctx = get_mock_global_ctx();
|
||||
let public_route: cidr::Ipv6Inet = "2001:db8::4/128".parse().unwrap();
|
||||
global_ctx.set_public_ipv6_routes(BTreeSet::from([public_route]));
|
||||
|
||||
let local_addr: SocketAddr = "[2001:db8::4]:1234".parse().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
easytier_managed_local_addr_error(&global_ctx, local_addr),
|
||||
Some("local address is easytier-managed ipv6")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listener_selection_prefers_reuse_before_cap() {
|
||||
assert!(!should_create_public_listener(1, true, true, false, false));
|
||||
|
||||
Reference in New Issue
Block a user