diff --git a/easytier/src/socket/tcp.rs b/easytier/src/socket/tcp.rs index e3c8f045..e534f303 100644 --- a/easytier/src/socket/tcp.rs +++ b/easytier/src/socket/tcp.rs @@ -303,7 +303,14 @@ pub(crate) async fn bind_tcp_listener( let addr = bind_options.local_addr.ok_or_else(|| { TunnelError::InvalidAddr("tcp listener requires a local bind address".to_owned()) })?; - if bind_options.bind_device.is_none() && purpose == TcpListenPurpose::PortLease { + if bind_options.bind_device.is_none() + && matches!( + purpose, + TcpListenPurpose::PortLease | TcpListenPurpose::PortForward + ) + { + // Port forwards follow the bind IP across interface recreation. An + // implicit device binding would retain the deleted interface's index. bind_options.bind_device = Some(String::new()); } let listener = create_tcp_socket(addr, &bind_options).await?.listen(1024)?; @@ -363,6 +370,22 @@ mod tests { use super::*; + #[cfg(target_os = "linux")] + #[rstest::rstest] + #[tokio::test] + async fn port_forward_listener_honors_device_binding( + #[values(None, Some("lo"), Some(""))] device: Option<&str>, + ) { + let mut options = TcpListenOptions::port_forward("127.0.0.1:0".parse().unwrap()); + options.bind.bind_device = device.map(str::to_owned); + let listener = bind_tcp_listener(options).await.unwrap(); + let bound = SockRef::from(&listener.listener).device().unwrap(); + assert_eq!( + bound.as_deref(), + device.filter(|name| !name.is_empty()).map(str::as_bytes) + ); + } + #[tokio::test] async fn runtime_tcp_owned_split_preserves_full_duplex_and_shutdown() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); diff --git a/easytier/src/socket/udp.rs b/easytier/src/socket/udp.rs index 15519094..b983c590 100644 --- a/easytier/src/socket/udp.rs +++ b/easytier/src/socket/udp.rs @@ -146,11 +146,11 @@ impl RuntimeUdpSocketFactory { return BindDev::from(bind_device.as_str()); } + // Port forwards bind only their local address by default, so replacing + // its interface does not strand the socket on the old interface index. if matches!( options.purpose, - UdpSocketPurpose::DirectConnect - | UdpSocketPurpose::PortBoundListener - | UdpSocketPurpose::PortForward + UdpSocketPurpose::DirectConnect | UdpSocketPurpose::PortBoundListener ) { return BindDev::Auto; } @@ -438,7 +438,7 @@ mod tests { )); assert!(matches!( factory.bind_device_for(&UdpBindOptions::port_forward(listener_addr)), - BindDev::Auto + BindDev::Disabled )); assert!(matches!( factory.bind_device_for(&UdpBindOptions::port_lease(listener_addr)), @@ -450,12 +450,16 @@ mod tests { )); } - #[test] - fn factory_applies_listener_bind_device_option() { + #[rstest::rstest] + fn factory_applies_listener_bind_device_option(#[values(false, true)] port_forward: bool) { let listener_addr = SocketAddr::from(([0, 0, 0, 0], 11010)); let factory = RuntimeUdpSocketFactory::new(); - let options = UdpBindOptions::port_bound_listener(listener_addr) - .with_bind_device(Some("eth0".to_owned())); + let options = if port_forward { + UdpBindOptions::port_forward(listener_addr) + } else { + UdpBindOptions::port_bound_listener(listener_addr) + } + .with_bind_device(Some("eth0".to_owned())); match factory.bind_device_for(&options) { BindDev::Custom(dev) => assert_eq!(dev, "eth0"), diff --git a/easytier/src/tests/three_node.rs b/easytier/src/tests/three_node.rs index 360cc5ee..ead1e162 100644 --- a/easytier/src/tests/three_node.rs +++ b/easytier/src/tests/three_node.rs @@ -2937,6 +2937,84 @@ pub async fn manual_reconnector(#[values(true, false)] is_foreign: bool) { drop_insts(vec![center_inst, inst1, inst2]).await; } +#[rstest::rstest] +#[tokio::test] +#[serial_test::serial] +async fn port_forward_survives_interface_recreation(#[values("tcp", "udp")] proto: &str) { + let network = prepare_linux_namespaces(); + let namespace = network.namespace("net_a"); + let create_tun = || { + run_ip_in_ns( + namespace, + &["tuntap", "add", "dev", "pf_tun", "mode", "tun"], + ); + run_ip_in_ns(namespace, &["addr", "add", "192.0.2.1/24", "dev", "pf_tun"]); + run_ip_in_ns(namespace, &["link", "set", "pf_tun", "up"]); + run_ip_in_ns_output(namespace, &["-o", "link", "show", "pf_tun"]) + .split(':') + .next() + .unwrap() + .to_owned() + }; + let original_index = create_tun(); + let insts = init_three_node_ex( + &network, + "udp", + |cfg| { + if cfg.get_inst_name() == "inst1" { + cfg.set_port_forwards(vec![PortForwardConfig { + bind_addr: "192.0.2.1:23456".parse().unwrap(), + dst_addr: "10.144.144.3:23456".parse().unwrap(), + proto: proto.to_owned(), + }]); + } + cfg + }, + false, + ) + .await; + + for recreated in [false, true] { + if recreated { + run_ip_in_ns(namespace, &["link", "del", "pf_tun"]); + assert_ne!(create_tun(), original_index); + } + // Keep the forwarding instance and its listener alive across deletion. + // Reusing the interface name and IP must not leave it bound to the old + // ifindex. Check an application round trip, including the return path. + let listener_url = format!("{proto}://0.0.0.0:23456").parse().unwrap(); + let connector_url = format!("{proto}://192.0.2.1:23456").parse().unwrap(); + let server_ns = NetNS::new(Some(network.namespace("net_c").into())); + let client_ns = NetNS::new(Some(namespace.into())); + let payload = b"port-forward interface recreation".to_vec(); + let timeout = Duration::from_secs(5); + let result = if proto == "tcp" { + _tunnel_pingpong_netns_with_timeout( + core_tcp_listener(listener_url), + core_tcp_dialer(connector_url), + server_ns, + client_ns, + payload, + timeout, + ) + .await + } else { + _tunnel_pingpong_netns_with_timeout( + core_udp_listener(listener_url), + core_udp_dialer(connector_url), + server_ns, + client_ns, + payload, + timeout, + ) + .await + }; + assert!(result.is_ok(), "{proto}, recreated={recreated}: {result:?}"); + } + + drop_insts(insts).await; +} + #[rstest::rstest] #[tokio::test] #[serial_test::serial]