diff --git a/easytier/src/dns/tests.rs b/easytier/src/dns/tests.rs index bec0a672..002da041 100644 --- a/easytier/src/dns/tests.rs +++ b/easytier/src/dns/tests.rs @@ -5,26 +5,31 @@ use std::str::FromStr as _; use std::sync::Arc; use std::time::{Duration, Instant}; -use cidr::Ipv4Inet; -use hickory_client::client::{Client, ClientHandle as _}; -use hickory_proto::rr; -use hickory_proto::runtime::TokioRuntimeProvider; -use hickory_proto::udp::UdpClientStream; -use tokio::sync::Notify; -use hickory_proto::rr::{Name, RecordType}; -use hickory_server::server::Request; -use hickory_proto::op::{Message, MessageType, OpCode, Query}; -use hickory_proto::serialize::binary::{BinDecodable, BinEncodable, BinEncoder}; -use hickory_server::authority::MessageRequest; -use hickory_proto::xfer::Protocol; +use crate::common::config::TomlConfigLoader; use crate::common::global_ctx::tests::get_mock_global_ctx; +use crate::common::global_ctx::GlobalCtx; use crate::connector::udp_hole_punch::tests::replace_stun_info_collector; use crate::dns::node::DnsNode; +use crate::dns::peer_mgr::DnsPeerMgr; +use crate::dns::config::zone::ZoneConfigInner; use crate::instance::instance::ArcNicCtx; use crate::instance::virtual_nic::NicCtx; use crate::peers::create_packet_recv_chan; use crate::peers::peer_manager::{PeerManager, RouteAlgoType}; +use crate::peers::tests::{connect_peer_manager, wait_route_appear}; use crate::proto::common::NatType; +use cidr::Ipv4Inet; +use hickory_client::client::{Client, ClientHandle as _}; +use hickory_proto::op::{Message, MessageType, OpCode, Query}; +use hickory_proto::rr; +use hickory_proto::rr::{Name, RecordType}; +use hickory_proto::runtime::TokioRuntimeProvider; +use hickory_proto::serialize::binary::{BinDecodable, BinEncodable, BinEncoder}; +use hickory_proto::udp::UdpClientStream; +use hickory_proto::xfer::Protocol; +use hickory_server::authority::MessageRequest; +use hickory_server::server::Request; +use tokio::sync::Notify; pub async fn prepare_env(dns_name: &str, tun_ip: Ipv4Inet) -> (Arc, NicCtx) { prepare_env_with_tld_dns_zone(dns_name, tun_ip, None).await @@ -72,17 +77,46 @@ pub fn start_dns_node(peer_mgr: Arc, virtual_nic: NicCtx) -> DnsNod dns_node } +pub fn start_dns_node_without_nic(peer_mgr: Arc) -> DnsNode { + let global_ctx = peer_mgr.get_global_ctx(); + let nic_ctx: ArcNicCtx = Arc::new(tokio::sync::Mutex::new(None)); + + let dns_node = DnsNode::new(peer_mgr, global_ctx, nic_ctx); + dns_node.start(); + dns_node +} + +pub async fn prepare_env_from_config_str(config_str: &str) -> Arc { + let config = TomlConfigLoader::new_from_str(config_str).expect("invalid test config"); + let ctx = Arc::new(GlobalCtx::new(config)); + + let (s, _r) = create_packet_recv_chan(); + let peer_mgr = Arc::new(PeerManager::new(RouteAlgoType::Ospf, ctx, s)); + peer_mgr.run().await.unwrap(); + replace_stun_info_collector(peer_mgr.clone(), NatType::PortRestricted); + + peer_mgr +} + +fn find_free_udp_port() -> u16 { + std::net::UdpSocket::bind("127.0.0.1:0") + .expect("failed to bind temp udp socket") + .local_addr() + .expect("failed to get local addr") + .port() +} + pub async fn check_dns_record(fake_ip: &Ipv4Addr, domain: &str, expected_ip: &str) { + check_dns_record_at(SocketAddr::new((*fake_ip).into(), 53), domain, expected_ip).await; +} + +pub async fn check_dns_record_at(server_addr: SocketAddr, domain: &str, expected_ip: &str) { let expected = expected_ip.parse::().unwrap(); let name = rr::Name::from_str(domain).unwrap(); let deadline = Instant::now() + Duration::from_secs(30); loop { - let stream = UdpClientStream::builder( - SocketAddr::new((*fake_ip).into(), 53), - TokioRuntimeProvider::default(), - ) - .build(); + let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build(); let (mut client, background) = Client::connect(stream).await.unwrap(); let background_task = tokio::spawn(background); @@ -125,26 +159,51 @@ pub async fn check_dns_record(fake_ip: &Ipv4Addr, domain: &str, expected_ip: &st } pub async fn check_dns_record_missing(fake_ip: &Ipv4Addr, domain: &str) { - let stream = UdpClientStream::builder( - SocketAddr::new((*fake_ip).into(), 53), - TokioRuntimeProvider::default(), - ) - .build(); - let (mut client, background) = Client::connect(stream).await.unwrap(); - let background_task = tokio::spawn(background); - let response = client - .query( - rr::Name::from_str(domain).unwrap(), - rr::DNSClass::IN, - rr::RecordType::A, + check_dns_record_missing_at(SocketAddr::new((*fake_ip).into(), 53), domain).await; +} + +pub async fn check_dns_record_missing_at(server_addr: SocketAddr, domain: &str) { + let deadline = Instant::now() + Duration::from_secs(30); + + loop { + let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build(); + let (mut client, background) = Client::connect(stream).await.unwrap(); + let background_task = tokio::spawn(background); + let query_result = tokio::time::timeout( + Duration::from_secs(2), + client.query( + rr::Name::from_str(domain).unwrap(), + rr::DNSClass::IN, + rr::RecordType::A, + ), ) - .await - .unwrap_or_else(|e| { - panic!("DNS query for missing record failed unexpectedly for domain '{domain}': {e}") - }); - background_task.abort(); - let _ = background_task.await; - assert!(response.answers().is_empty(), "{:?}", response.answers()); + .await; + background_task.abort(); + let _ = background_task.await; + + let attempt_err = match query_result { + Ok(Ok(response)) => { + if response.answers().is_empty() { + return; + } + format!("unexpected non-empty response: {:?}", response.answers()) + } + Ok(Err(e)) => { + format!("DNS query for missing record failed for domain '{domain}': {e}") + } + Err(_) => { + format!("DNS query for missing record timed out for domain '{domain}'") + } + }; + + if Instant::now() >= deadline { + panic!( + "missing-record query failed unexpectedly for domain '{domain}' after retries: {attempt_err}" + ); + } + + tokio::time::sleep(Duration::from_millis(500)).await; + } } pub fn new_request(name: &str, rtype: RecordType) -> anyhow::Result { @@ -164,4 +223,687 @@ pub fn new_request(name: &str, rtype: RecordType) -> anyhow::Result { SocketAddrV4::new(Ipv4Addr::LOCALHOST, 0).into(), Protocol::Udp, )) -} \ No newline at end of file +} + +async fn wait_route_disappear(peer_mgr: Arc, target_peer_id: u32) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let has_route = peer_mgr + .list_routes() + .await + .iter() + .any(|r| r.peer_id == target_peer_id); + if !has_route { + return; + } + + assert!( + Instant::now() < deadline, + "route to peer {} did not disappear in time", + target_peer_id + ); + tokio::time::sleep(Duration::from_millis(100)).await; + } +} + +async fn disconnect_all_peer_conns(a: Arc, b: Arc) { + if let Some(conns) = a.get_peer_map().list_peer_conns(b.my_peer_id()).await { + for conn in conns { + let conn_id = conn.conn_id.parse().expect("invalid conn id"); + let _ = a.close_peer_conn(b.my_peer_id(), &conn_id).await; + } + } + if let Some(conns) = b.get_peer_map().list_peer_conns(a.my_peer_id()).await { + for conn in conns { + let conn_id = conn.conn_id.parse().expect("invalid conn id"); + let _ = b.close_peer_conn(a.my_peer_id(), &conn_id).await; + } + } +} + +async fn check_dns_unavailable_at(server_addr: SocketAddr, domain: &str) { + let deadline = Instant::now() + Duration::from_secs(15); + let name = rr::Name::from_str(domain).unwrap(); + + loop { + let stream = UdpClientStream::builder(server_addr, TokioRuntimeProvider::default()).build(); + let connect = Client::connect(stream).await; + + if let Ok((mut client, background)) = connect { + let background_task = tokio::spawn(background); + let query_result = tokio::time::timeout( + Duration::from_secs(1), + client.query(name.clone(), rr::DNSClass::IN, rr::RecordType::A), + ) + .await; + + background_task.abort(); + let _ = background_task.await; + + match query_result { + Ok(Ok(response)) if !response.answers().is_empty() => { + if Instant::now() >= deadline { + panic!( + "DNS endpoint {server_addr} still answered for '{domain}': {:?}", + response.answers() + ); + } + } + _ => return, + } + } else { + return; + } + + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +async fn wait_peer_zone_visibility( + peer_mgr: Arc, + target_peer_id: u32, + zone_origin_substr: &str, + expected_visible: bool, +) { + let dns = DnsPeerMgr::new(peer_mgr.clone(), peer_mgr.get_global_ctx()); + let deadline = Instant::now() + Duration::from_secs(20); + + loop { + dns.refresh(target_peer_id).await; + let visible = dns + .snapshot() + .zones + .iter() + .any(|z| z.origin.contains(zone_origin_substr)); + + if visible == expected_visible { + return; + } + + assert!( + Instant::now() < deadline, + "zone visibility mismatch for '{}': expected {}, got {}", + zone_origin_substr, + expected_visible, + visible + ); + tokio::time::sleep(Duration::from_millis(200)).await; + } +} + +fn cfg_with_listener(name: &str, ipv4: &str, domain: &str, listener_port: u16) -> String { + format!( + r#" +instance_name = "dns-int-{name}" +ipv4 = "{ipv4}" + +[dns] +name = "{name}" +domain = "{domain}" +listeners = ["udp://127.0.0.1:{listener_port}"] +"# + ) +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_single_node_resolves_self_and_custom_zone() { + let listener_port = find_free_udp_port(); + let config = format!( + r#" +{} + +[[dns.zone]] +origin = "services.alpha.et-test" +records = ["api IN A 10.77.0.7"] + +[dns.zone.export] +whitelist = ["*"] +"#, + cfg_with_listener("alpha", "10.144.144.11/24", "et-test", listener_port) + ); + + let peer_mgr = prepare_env_from_config_str(&config).await; + let dns_node = start_dns_node_without_nic(peer_mgr); + + let server_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_port); + check_dns_record_at(server_addr, "alpha.et-test.", "10.144.144.11").await; + check_dns_record_at(server_addr, "api.services.alpha.et-test.", "10.77.0.7").await; + + dns_node.stop().await.unwrap(); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_two_nodes_sync_self_zone_and_exported_zone() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + + let config_a = format!( + r#" +{} + +[[dns.zone]] +origin = "shared.mesh-test" +records = ["app IN A 10.66.0.8"] + +[dns.zone.export] +whitelist = ["*"] +"#, + cfg_with_listener("node-a", "10.144.144.21/24", "mesh-test", listener_a) + ); + + let config_b = cfg_with_listener("node-b", "10.144.144.22/24", "mesh-test", listener_b); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .expect("route should appear"); + + let node_a = start_dns_node_without_nic(peer_a); + let node_b = start_dns_node_without_nic(peer_b); + + let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a); + let addr_b = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_b); + + check_dns_record_at(addr_a, "node-b.mesh-test.", "10.144.144.22").await; + check_dns_record_at(addr_b, "node-b.mesh-test.", "10.144.144.22").await; + check_dns_record_at(addr_a, "app.shared.mesh-test.", "10.66.0.8").await; + check_dns_record_at(addr_b, "app.shared.mesh-test.", "10.66.0.8").await; + + node_a.stop().await.unwrap(); + node_b.stop().await.unwrap(); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_zone_without_export_section_is_not_synced() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + + let config_a = format!( + r#" +{} + +[[dns.zone]] +origin = "private.mesh-test" +records = ["secret IN A 10.99.0.9"] +"#, + cfg_with_listener("local-a", "10.144.144.31/24", "mesh-test", listener_a) + ); + + let config_b = cfg_with_listener("local-b", "10.144.144.32/24", "mesh-test", listener_b); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .expect("route should appear"); + + // Export behavior is determined by whether `[dns.zone.export]` exists. + // Verify from peer-sync view to avoid host-wide DNS-server election side effects. + let dns_a = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx()); + dns_a.register(); + + let dns_b = DnsPeerMgr::new(peer_b.clone(), peer_b.get_global_ctx()); + dns_b.refresh(peer_a.my_peer_id()).await; + + let snapshot = dns_b.snapshot(); + assert!( + !snapshot + .zones + .iter() + .any(|z| z.origin.contains("private.mesh-test")), + "zone without [dns.zone.export] should not be exported to peer snapshot" + ); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_export_section_disabled_is_not_synced() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + + let config_a = format!( + r#" +{} + +[[dns.zone]] +origin = "disabled.mesh-test" +records = ["secret IN A 10.99.1.9"] + +[dns.zone.export] +disabled = true +"#, + cfg_with_listener("local-da", "10.144.144.41/24", "mesh-test", listener_a) + ); + + let config_b = cfg_with_listener("local-db", "10.144.144.42/24", "mesh-test", listener_b); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .expect("route should appear"); + + let dns_a = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx()); + dns_a.register(); + + let dns_b = DnsPeerMgr::new(peer_b.clone(), peer_b.get_global_ctx()); + dns_b.refresh(peer_a.my_peer_id()).await; + + let snapshot = dns_b.snapshot(); + assert!( + !snapshot + .zones + .iter() + .any(|z| z.origin.contains("disabled.mesh-test")), + "zone with [dns.zone.export] disabled=true should not be exported" + ); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_patch_updates_zone_record_visible_on_query() { + let listener = find_free_udp_port(); + let config = format!( + r#" +{} + +[[dns.zone]] +origin = "patch.mesh-test" +records = ["api IN A 10.80.0.1"] + +[dns.zone.export] +"#, + cfg_with_listener("patch-node", "10.144.149.11/24", "mesh-test", listener) + ); + + let peer = prepare_env_from_config_str(&config).await; + let dns_node = start_dns_node_without_nic(peer.clone()); + let server_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener); + + check_dns_record_at(server_addr, "api.patch.mesh-test.", "10.80.0.1").await; + + let mut dns = peer.get_global_ctx().config.get_dns(); + let zone_idx = dns + .zones + .iter() + .position(|z| z.origin.to_string().contains("patch.mesh-test")) + .expect("patch zone should exist"); + let mut zone: ZoneConfigInner = dns.zones[zone_idx].clone().into(); + zone.records = vec!["api IN A 10.80.0.2".to_string()]; + dns.zones[zone_idx] = zone.try_into().expect("patch zone update should be valid"); + peer.get_global_ctx().config.set_dns(Some(dns)); + peer.get_global_ctx().issue_event( + crate::common::global_ctx::GlobalCtxEvent::ConfigPatched( + crate::proto::api::config::InstanceConfigPatch::default(), + ), + ); + + check_dns_record_at(server_addr, "api.patch.mesh-test.", "10.80.0.2").await; + + dns_node.stop().await.unwrap(); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_patch_reloads_listener_binding() { + let listener_old = find_free_udp_port(); + let listener_new = find_free_udp_port(); + let config = cfg_with_listener("listener-patch", "10.144.150.11/24", "mesh-test", listener_old); + + let peer = prepare_env_from_config_str(&config).await; + let dns_node = start_dns_node_without_nic(peer.clone()); + + let old_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_old); + let new_addr = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_new); + check_dns_record_at(old_addr, "listener-patch.mesh-test.", "10.144.150.11").await; + + let mut dns = peer.get_global_ctx().config.get_dns(); + dns.listeners = vec![format!("udp://127.0.0.1:{listener_new}") + .parse() + .expect("invalid listener")] + .into(); + peer.get_global_ctx().config.set_dns(Some(dns)); + peer.get_global_ctx().issue_event( + crate::common::global_ctx::GlobalCtxEvent::ConfigPatched( + crate::proto::api::config::InstanceConfigPatch::default(), + ), + ); + + check_dns_record_at(new_addr, "listener-patch.mesh-test.", "10.144.150.11").await; + check_dns_unavailable_at(old_addr, "listener-patch.mesh-test.").await; + + dns_node.stop().await.unwrap(); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_three_nodes_partition_and_recover_dns_propagation() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + let listener_c = find_free_udp_port(); + + let config_a = cfg_with_listener("node-a7", "10.144.151.11/24", "mesh7-test", listener_a); + let config_b = cfg_with_listener("node-b7", "10.144.151.12/24", "mesh7-test", listener_b); + let config_c = format!( + r#" +{} + +[[dns.zone]] +origin = "shared-c7.mesh7-test" +records = ["svc IN A 10.77.7.7"] + +[dns.zone.export] +"#, + cfg_with_listener("node-c7", "10.144.151.13/24", "mesh7-test", listener_c) + ); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + let peer_c = prepare_env_from_config_str(&config_c).await; + + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + connect_peer_manager(peer_b.clone(), peer_c.clone()).await; + wait_route_appear(peer_a.clone(), peer_c.clone()) + .await + .expect("route a-c should appear via b"); + + let node_a = start_dns_node_without_nic(peer_a.clone()); + let node_b = start_dns_node_without_nic(peer_b.clone()); + let node_c = start_dns_node_without_nic(peer_c.clone()); + + let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a); + check_dns_record_at(addr_a, "node-c7.mesh7-test.", "10.144.151.13").await; + check_dns_record_at(addr_a, "svc.shared-c7.mesh7-test.", "10.77.7.7").await; + wait_peer_zone_visibility(peer_a.clone(), peer_c.my_peer_id(), "node-c7.mesh7-test", true).await; + wait_peer_zone_visibility( + peer_a.clone(), + peer_c.my_peer_id(), + "shared-c7.mesh7-test", + true, + ) + .await; + + disconnect_all_peer_conns(peer_b.clone(), peer_c.clone()).await; + wait_route_disappear(peer_a.clone(), peer_c.my_peer_id()).await; + // Validate via peer-sync snapshot to avoid process-wide DNS-server election side effects. + wait_peer_zone_visibility(peer_a.clone(), peer_c.my_peer_id(), "node-c7.mesh7-test", false) + .await; + wait_peer_zone_visibility( + peer_a.clone(), + peer_c.my_peer_id(), + "shared-c7.mesh7-test", + false, + ) + .await; + + connect_peer_manager(peer_b.clone(), peer_c.clone()).await; + wait_route_appear(peer_a.clone(), peer_c.clone()) + .await + .expect("route a-c should recover via b"); + + wait_peer_zone_visibility(peer_a.clone(), peer_c.my_peer_id(), "node-c7.mesh7-test", true).await; + wait_peer_zone_visibility( + peer_a.clone(), + peer_c.my_peer_id(), + "shared-c7.mesh7-test", + true, + ) + .await; + + check_dns_record_at(addr_a, "node-c7.mesh7-test.", "10.144.151.13").await; + check_dns_record_at(addr_a, "svc.shared-c7.mesh7-test.", "10.77.7.7").await; + + node_a.stop().await.unwrap(); + node_b.stop().await.unwrap(); + node_c.stop().await.unwrap(); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_three_nodes_chain_sync_self_and_exported_zone() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + let listener_c = find_free_udp_port(); + + let config_a = cfg_with_listener("node-a3", "10.144.145.11/24", "mesh3-test", listener_a); + let config_b = cfg_with_listener("node-b3", "10.144.145.12/24", "mesh3-test", listener_b); + let config_c = format!( + r#" +{} + +[[dns.zone]] +origin = "shared-c.mesh3-test" +records = ["api IN A 10.66.1.8"] + +[dns.zone.export] +"#, + cfg_with_listener("node-c3", "10.144.145.13/24", "mesh3-test", listener_c) + ); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + let peer_c = prepare_env_from_config_str(&config_c).await; + + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + connect_peer_manager(peer_b.clone(), peer_c.clone()).await; + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .expect("route a-b should appear"); + wait_route_appear(peer_b.clone(), peer_c.clone()) + .await + .expect("route b-c should appear"); + wait_route_appear(peer_a.clone(), peer_c.clone()) + .await + .expect("route a-c should appear via b"); + + let node_a = start_dns_node_without_nic(peer_a); + let node_b = start_dns_node_without_nic(peer_b); + let node_c = start_dns_node_without_nic(peer_c); + + let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a); + let addr_b = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_b); + let addr_c = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_c); + + check_dns_record_at(addr_a, "node-c3.mesh3-test.", "10.144.145.13").await; + check_dns_record_at(addr_b, "node-c3.mesh3-test.", "10.144.145.13").await; + check_dns_record_at(addr_c, "node-a3.mesh3-test.", "10.144.145.11").await; + check_dns_record_at(addr_a, "api.shared-c.mesh3-test.", "10.66.1.8").await; + check_dns_record_at(addr_c, "api.shared-c.mesh3-test.", "10.66.1.8").await; + + node_a.stop().await.unwrap(); + node_b.stop().await.unwrap(); + node_c.stop().await.unwrap(); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_three_nodes_late_join_propagates_dns() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + let listener_c = find_free_udp_port(); + + let config_a = cfg_with_listener("node-a4", "10.144.146.11/24", "mesh4-test", listener_a); + let config_b = cfg_with_listener("node-b4", "10.144.146.12/24", "mesh4-test", listener_b); + let config_c = format!( + r#" +{} + +[[dns.zone]] +origin = "joined.mesh4-test" +records = ["svc IN A 10.66.2.8"] + +[dns.zone.export] +"#, + cfg_with_listener("node-c4", "10.144.146.13/24", "mesh4-test", listener_c) + ); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + let peer_c = prepare_env_from_config_str(&config_c).await; + + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .expect("route a-b should appear"); + + let node_a = start_dns_node_without_nic(peer_a.clone()); + let node_b = start_dns_node_without_nic(peer_b.clone()); + + let addr_a = SocketAddr::new(Ipv4Addr::LOCALHOST.into(), listener_a); + check_dns_record_missing_at(addr_a, "node-c4.mesh4-test.").await; + + connect_peer_manager(peer_b.clone(), peer_c.clone()).await; + wait_route_appear(peer_b.clone(), peer_c.clone()) + .await + .expect("route b-c should appear"); + wait_route_appear(peer_a.clone(), peer_c.clone()) + .await + .expect("route a-c should appear after c joins"); + + let node_c = start_dns_node_without_nic(peer_c); + + check_dns_record_at(addr_a, "node-c4.mesh4-test.", "10.144.146.13").await; + check_dns_record_at(addr_a, "svc.joined.mesh4-test.", "10.66.2.8").await; + + node_a.stop().await.unwrap(); + node_b.stop().await.unwrap(); + node_c.stop().await.unwrap(); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_three_nodes_zone_without_export_not_synced_across_hop() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + let listener_c = find_free_udp_port(); + + let config_a = cfg_with_listener("node-a5", "10.144.147.11/24", "mesh5-test", listener_a); + let config_b = cfg_with_listener("node-b5", "10.144.147.12/24", "mesh5-test", listener_b); + let config_c = format!( + r#" +{} + +[[dns.zone]] +origin = "private-c.mesh5-test" +records = ["secret IN A 10.66.3.8"] +"#, + cfg_with_listener("node-c5", "10.144.147.13/24", "mesh5-test", listener_c) + ); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + let peer_c = prepare_env_from_config_str(&config_c).await; + + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + connect_peer_manager(peer_b.clone(), peer_c.clone()).await; + wait_route_appear(peer_a.clone(), peer_c.clone()) + .await + .expect("route a-c should appear via b"); + + let dns_c = DnsPeerMgr::new(peer_c.clone(), peer_c.get_global_ctx()); + dns_c.register(); + + let dns_a = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx()); + dns_a.refresh(peer_c.my_peer_id()).await; + + let snapshot = dns_a.snapshot(); + assert!( + !snapshot + .zones + .iter() + .any(|z| z.origin.contains("private-c.mesh5-test")), + "zone without [dns.zone.export] should not sync over multi-hop" + ); +} + +#[tokio::test] +#[serial_test::serial(dns_integration_rpc)] +async fn config_string_two_nodes_peer_dns_offline_then_rejoin() { + let listener_a = find_free_udp_port(); + let listener_b = find_free_udp_port(); + + let config_a = cfg_with_listener("node-a6", "10.144.148.11/24", "mesh6-test", listener_a); + let config_b = cfg_with_listener("node-b6", "10.144.148.12/24", "mesh6-test", listener_b); + + let peer_a = prepare_env_from_config_str(&config_a).await; + let peer_b = prepare_env_from_config_str(&config_b).await; + + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .expect("route should appear"); + + let dns_b = DnsPeerMgr::new(peer_b.clone(), peer_b.get_global_ctx()); + dns_b.register(); + + let dns_a_online = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx()); + dns_a_online.refresh(peer_b.my_peer_id()).await; + assert!( + dns_a_online + .snapshot() + .zones + .iter() + .any(|z| z.origin.contains("node-b6.mesh6-test")), + "peer B self zone should be visible after initial refresh" + ); + + // Simulate peer offline by closing all direct connections and waiting route withdrawal. + if let Some(conns) = peer_a + .get_peer_map() + .list_peer_conns(peer_b.my_peer_id()) + .await + { + for conn in conns { + let conn_id = conn.conn_id.parse().expect("invalid conn id"); + let _ = peer_a.close_peer_conn(peer_b.my_peer_id(), &conn_id).await; + } + } + if let Some(conns) = peer_b + .get_peer_map() + .list_peer_conns(peer_a.my_peer_id()) + .await + { + for conn in conns { + let conn_id = conn.conn_id.parse().expect("invalid conn id"); + let _ = peer_b.close_peer_conn(peer_a.my_peer_id(), &conn_id).await; + } + } + + wait_route_disappear(peer_a.clone(), peer_b.my_peer_id()).await; + wait_route_disappear(peer_b.clone(), peer_a.my_peer_id()).await; + + // Cached remote zones should be purged after peer cache idle timeout. + tokio::time::sleep(Duration::from_secs(4)).await; + let dns_a_offline = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx()); + assert!( + !dns_a_offline + .snapshot() + .zones + .iter() + .any(|z| z.origin.contains("node-b6.mesh6-test")), + "peer B self zone should disappear after route withdrawal and cache expiry" + ); + + connect_peer_manager(peer_a.clone(), peer_b.clone()).await; + wait_route_appear(peer_a.clone(), peer_b.clone()) + .await + .expect("route should re-appear"); + + let dns_a_rejoin = DnsPeerMgr::new(peer_a.clone(), peer_a.get_global_ctx()); + dns_a_rejoin.refresh(peer_b.my_peer_id()).await; + assert!( + dns_a_rejoin + .snapshot() + .zones + .iter() + .any(|z| z.origin.contains("node-b6.mesh6-test")), + "peer B self zone should be restored after DNS RPC rejoins" + ); +}