Compare commits

...
Author SHA1 Message Date
KKRainbow a24cc0a1bb fix(socket): keep port forwards usable after interface recreation
Bind TCP and UDP port-forward listeners to their configured address
instead of implicitly pinning them to the current network interface.
An interface recreated with the same name and IP gets a new ifindex,
leaving device-bound listeners unable to receive traffic while accept
continues waiting. Keep explicit device-binding options unchanged.

Add TCP and UDP three-node regressions that recreate a TUN interface
without restarting the forwarding instance, plus device-option checks.

Fixes #2714.
2026-10-21 00:39:23 +08:00
3 changed files with 114 additions and 9 deletions
+24 -1
View File
@@ -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();
+12 -8
View File
@@ -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"),
+78
View File
@@ -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]