From 5af90471bfe654d097f7e488a2699f2850db7dd2 Mon Sep 17 00:00:00 2001 From: KKRainbow <5665404+KKRainbow@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:04:29 +0800 Subject: [PATCH] fix(vpn): inherit portal exit nodes for attached clients (#2726) --- easytier-core/src/peers/attached.rs | 176 +++++++++++++++++++++++- easytier-core/src/peers/peer_manager.rs | 7 + easytier/src/tests/three_node.rs | 15 +- 3 files changed, 195 insertions(+), 3 deletions(-) diff --git a/easytier-core/src/peers/attached.rs b/easytier-core/src/peers/attached.rs index d7f77872..88efdc59 100644 --- a/easytier-core/src/peers/attached.rs +++ b/easytier-core/src/peers/attached.rs @@ -199,7 +199,7 @@ impl AttachedPeerRuntime { let public_ipv6_runtime = CorePublicIpv6Runtime::new(runtime_config.clone(), Arc::new(()), Arc::new(())); let flags = peer_snapshot.flags.clone(); - let peer_manager = Arc::new(PeerManagerCore::new( + let mut peer_manager = PeerManagerCore::new( PortablePeerManagerConfig { snapshot: peer_snapshot, route_algo: RouteAlgoType::Ospf, @@ -214,7 +214,9 @@ impl AttachedPeerRuntime { Arc::new(()), None, Arc::new(()), - )?); + )?; + peer_manager.inherit_exit_nodes(&network_peer_manager); + let peer_manager = Arc::new(peer_manager); if let Err(error) = peer_manager .follow_network_policy(network_runtime_config, config.groups) .await @@ -621,6 +623,176 @@ mod tests { peer_manager_with_acl(Vec::new()) } + async fn connect_exit_peer( + network: &PeerManagerCore, + store: &CoreRuntimeConfigStore, + address: Ipv4Addr, + ) -> (Arc, HostPacketReceiver) { + let mut snapshot = store.snapshot().peer.as_ref().clone(); + snapshot.runtime.core.routes.ipv4 = Some(IpPrefix { + address: address.into(), + prefix_len: 24, + }); + if let Some(secure) = snapshot.runtime.secure_mode.as_mut() { + let private = StaticSecret::from([address.octets()[3]; 32]); + secure.local_private_key = Some(BASE64_STANDARD.encode(private.as_bytes())); + secure.local_public_key = + Some(BASE64_STANDARD.encode(PublicKey::from(&private).as_bytes())); + } + let flags = snapshot.flags.clone(); + let store = + CoreRuntimeConfigStore::new(CoreRuntimeConfig::default(), Arc::new(snapshot.clone())); + let (sender, receiver) = host_packet_channel(); + let public_ipv6 = CorePublicIpv6Runtime::new(store.clone(), Arc::new(()), Arc::new(())); + let peer = Arc::new( + PeerManagerCore::new( + PortablePeerManagerConfig { + snapshot, + route_algo: RouteAlgoType::Ospf, + exit_nodes: Vec::new(), + foreign_context_default_flags: flags, + }, + Vec::new(), + store, + Arc::new(()), + sender, + public_ipv6, + Arc::new(()), + None, + Arc::new(()), + ) + .unwrap(), + ); + peer.run().await.unwrap(); + let (server, client) = create_ring_tunnel_pair(); + let (server_result, client_result) = tokio::join!( + network.add_tunnel_as_server(server), + peer.add_client_tunnel(client), + ); + server_result.unwrap(); + client_result.unwrap(); + (peer, receiver) + } + + async fn check_attached_peer_exit_nodes(secure: bool) { + let (network, store) = peer_manager_with_acl_and_secure(Vec::new(), secure); + network.run().await.unwrap(); + let first_ip = Ipv4Addr::new(10, 82, 0, 10); + let second_ip = Ipv4Addr::new(10, 82, 0, 11); + let (first, mut first_packets) = connect_exit_peer(&network, &store, first_ip).await; + let (second, mut second_packets) = connect_exit_peer(&network, &store, second_ip).await; + network.update_exit_nodes(vec![first_ip.into()]).await; + let client_ip = Ipv4Addr::new(10, 82, 0, 2); + let attached = AttachedPeerRuntime::connect( + network.clone(), + store, + AttachedPeerConfig { + name: "exit-node-client".to_owned(), + virtual_ip: attached_ipv4(client_ip), + groups: vec!["ops".to_owned()], + identity_private_key: [2; 32], + }, + ) + .await + .unwrap(); + for (exit, address) in [(&first, first_ip), (&second, second_ip)] { + wait_for_route(&attached.peer_manager, exit.my_peer_id(), address, true).await; + wait_for_route(exit, attached.peer_id(), client_ip, true).await; + } + + let public_ip = Ipv4Addr::new(8, 8, 8, 8); + let mut request = vec![0u8; 28]; + request[0] = 0x45; + request[2..4].copy_from_slice(&28u16.to_be_bytes()); + request[8] = 64; + request[9] = 1; + request[12..16].copy_from_slice(&client_ip.octets()); + request[16..20].copy_from_slice(&public_ip.octets()); + request[20] = 8; + + for (exit, address, packets) in [ + (&first, first_ip, &mut first_packets), + (&second, second_ip, &mut second_packets), + ] { + if address == second_ip { + network.update_exit_nodes(vec![second_ip.into()]).await; + } + assert_eq!( + attached + .peer_manager + .get_msg_dst_peer(&public_ip.into()) + .await, + (vec![exit.my_peer_id()], true), + ); + attached.send_packet(&request).await.unwrap(); + let packet = tokio::time::timeout(Duration::from_secs(5), packets.recv()) + .await + .expect("exit node did not receive the client packet") + .unwrap() + .into_core_packet(); + assert_eq!(packet.payload(), request.as_slice()); + let header = packet.peer_manager_header().unwrap(); + assert!(header.is_exit_node()); + assert_eq!(header.from_peer_id.get(), attached.peer_id()); + assert_eq!(header.to_peer_id.get(), exit.my_peer_id()); + + let mut reply = request.clone(); + reply[12..16].copy_from_slice(&public_ip.octets()); + reply[16..20].copy_from_slice(&client_ip.octets()); + reply[20] = 0; + exit.send_msg_by_ip(ZCPacket::new_with_payload(&reply), client_ip.into(), false) + .await + .unwrap(); + let received = tokio::time::timeout(Duration::from_secs(5), attached.recv_packet()) + .await + .expect("exit reply did not reach the client") + .unwrap(); + assert_eq!(received.payload(), reply.as_slice()); + } + + network.update_exit_nodes(Vec::new()).await; + assert_eq!( + attached + .peer_manager + .get_msg_dst_peer(&public_ip.into()) + .await, + (Vec::new(), false), + ); + // Explicit mesh routes must still win, even with an exit configured. + network.update_exit_nodes(vec![second_ip.into()]).await; + assert_eq!( + attached + .peer_manager + .get_msg_dst_peer(&first_ip.into()) + .await, + (vec![first.my_peer_id()], false), + ); + request[16..20].copy_from_slice(&first_ip.octets()); + attached.send_packet(&request).await.unwrap(); + let packet = tokio::time::timeout(Duration::from_secs(5), first_packets.recv()) + .await + .unwrap() + .unwrap() + .into_core_packet(); + assert_eq!(packet.payload(), request.as_slice()); + assert!(!packet.peer_manager_header().unwrap().is_exit_node()); + + attached.close().await; + first.clear_resources().await; + second.clear_resources().await; + network.clear_resources().await; + } + + #[tokio::test] + async fn attached_peer_inherits_exit_nodes_and_updates() { + check_attached_peer_exit_nodes(false).await; + } + + #[tokio::test] + async fn secure_attached_peer_inherits_exit_nodes_and_updates() { + check_attached_peer_exit_nodes(true).await; + } + async fn wait_for_route( peer: &PeerManagerCore, attached_peer_id: PeerId, diff --git a/easytier-core/src/peers/peer_manager.rs b/easytier-core/src/peers/peer_manager.rs index 558f34de..b95414c5 100644 --- a/easytier-core/src/peers/peer_manager.rs +++ b/easytier-core/src/peers/peer_manager.rs @@ -1756,6 +1756,13 @@ impl PeerManagerCore { *self.exit_nodes.write().await = exit_nodes; } + /// Shares the network's current exit nodes, including later updates, while + /// keeping this peer's route lookup and packet identity independent. + pub(super) fn inherit_exit_nodes(&mut self, network: &Self) { + self.exit_nodes = network.exit_nodes.clone(); + self.outbound_packet_router.exit_nodes = self.exit_nodes.clone(); + } + fn reload_acl(&self, acl: Option<&crate::proto::acl::Acl>) { self.acl_filter.reload_rules(acl); } diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index e7567522..360cc5ee 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -2075,8 +2075,13 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) { config .add_proxy_cidr("198.51.100.0/24".parse().unwrap(), None) .unwrap(); + let mut flags = config.get_flags(); + flags.enable_exit_node = true; + flags.use_smoltcp = true; + config.set_flags(flags); } if config.get_inst_name() == "inst3" { + config.set_exit_nodes(vec!["10.144.144.1".parse().unwrap()]); config.set_vpn_portal_config(VpnPortalConfig { enabled: None, wireguard_listen: "0.0.0.0:22121".parse().unwrap(), @@ -2137,7 +2142,7 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) { dst_socket_addr, Key::try_from(server_public.as_slice()).unwrap(), Key::try_from(client_private.as_slice()).unwrap(), - vec!["10.144.144.0/24".to_string()], + vec!["10.144.144.0/24".to_string(), "10.1.1.1/32".to_string()], "10.144.144.4".to_owned(), ) .unwrap(); @@ -2161,6 +2166,14 @@ pub async fn wireguard_vpn_portal(#[values(true, false)] test_v6: bool) { ) .await; + // This address is outside the mesh and advertised proxy CIDRs, on a + // separate bridge reachable only through the configured exit node. + wait_for_condition( + || async { ping_test(network.namespace("net_d"), "10.1.1.1", None).await }, + Duration::from_secs(5), + ) + .await; + drop_insts(insts).await; }