From c5dc6bfe819a808b932ae24839de9c26170718b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thomas=20B=C3=BCngener?= Date: Wed, 15 Jul 2026 08:05:50 +0200 Subject: [PATCH] Add interface / source-IP binding for connections Bind sockets to a chosen network interface or source IP, for incoming and outgoing connections, via the bind-interface option (off by default). The value is a source address when it parses as one and an interface name otherwise, the same rule libtorrent uses for outgoing_interfaces. An interface name also pins the socket to the device (SO_BINDTODEVICE on Linux/Android, IP_BOUND_IF on macOS/iOS, IP_UNICAST_IF on Windows) so egress follows it regardless of the routing table. Anything unusable -- an interface that is gone, or one with no address of the requested family -- falls back to letting the os choose. The device is only pinned when the same decision produced a source address, so pinning can never contradict the fallback. Covered: outgoing connections, the direct-access listener, the direct UDP setup path and its reachability probe, and the websocket transport, which dials through a socket we bind rather than letting tungstenite open an unbound one. Explicit listeners are not pinned: new_listener binds caller-supplied addresses such as the port-forward and RDP listeners on 127.0.0.1, and SO_BINDTODEVICE there would filter out traffic arriving on loopback. Interface names are resolved to the system device name before pinning, since if_nametoindex and SO_BINDTODEVICE do not accept friendly names, and Windows IPv6 uses the adapter's IPv6 scope id rather than the IPv4 interface index. Interface enumeration uses netdev 0.37, the newest release that still builds on the declared Rust 1.75. Adds unit tests for the decision matrix and loopback integration tests for the socket layer, the listener and the websocket transport. --- Cargo.toml | 6 + src/config.rs | 447 ++++++++++++++++++++++++++++++++++++++++ src/socket_client.rs | 21 +- src/tcp.rs | 50 ++++- src/udp.rs | 22 +- src/websocket.rs | 47 ++++- tests/bind_interface.rs | 188 +++++++++++++++++ 7 files changed, 765 insertions(+), 16 deletions(-) create mode 100644 tests/bind_interface.rs diff --git a/Cargo.toml b/Cargo.toml index 11a49653a2..fe25b0b3f7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,11 @@ libloading = "0.8" [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] mac_address = "1.1" default_net = { git = "https://github.com/rustdesk-org/default_net" } +# netdev (renamed to avoid clashing with the `default_net` fork above, which +# only exposes `get_mac`) provides interface/address enumeration used to bind +# sockets to a specific network interface. It is the maintained successor of +# the default-net crate that fork was taken from. +netif = { package = "netdev", version = "0.37" } machine-uid = { git = "https://github.com/rustdesk-org/machine-uid" } [build-dependencies] @@ -90,6 +95,7 @@ winapi = { version = "0.3", features = [ "pdh", "memoryapi", "sysinfoapi", + "winsock2", ] } [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src/config.rs b/src/config.rs index a3eba12154..6a3019536a 100644 --- a/src/config.rs +++ b/src/config.rs @@ -900,6 +900,10 @@ impl Config { #[inline] pub fn get_any_listen_addr(is_ipv4: bool) -> SocketAddr { + // use the configured bind source ip if any, else the unspecified addr + if let Some(ip) = Self::get_bind_source_ip(is_ipv4) { + return SocketAddr::new(ip, 0); + } if is_ipv4 { SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0) } else { @@ -907,6 +911,51 @@ impl Config { } } + fn bind_decision(is_ipv4: bool) -> BindDecision { + let value = Self::get_option(keys::OPTION_BIND_INTERFACE); + // default path: nothing configured, so don't enumerate interfaces on + // every socket we create + if value.is_empty() { + return BindDecision::Any; + } + decide_bind(&value, is_ipv4, local_ip_exists, interface_source_ip) + } + + // Source ip to bind outgoing sockets to per bind-interface, or None to let + // the os choose. Also None when the configured interface/address is gone or + // has no address of this family: binding falls back to all interfaces + // rather than cutting the connection. + pub fn get_bind_source_ip(is_ipv4: bool) -> Option { + match Self::bind_decision(is_ipv4) { + // PinOnly sets no source address; apply_bind_device does the work + BindDecision::Any | BindDecision::PinOnly => None, + BindDecision::Use(ip) => Some(ip), + } + } + + // Ingress counterpart of get_bind_source_ip: the address a listener should + // bind, or None to listen on all interfaces. + pub fn get_bind_listen_ip(is_ipv4: bool) -> Option { + Self::get_bind_source_ip(is_ipv4) + } + + // Interface to pin sockets to (see bind_socket_to_interface), as the system + // device name -- `if_nametoindex` and SO_BINDTODEVICE do not accept the + // friendly names that may be configured. None when nothing is configured, + // when the value is an address (there is no device to pin), or when this + // family falls back to all interfaces, so pinning never contradicts the + // source address decision. + pub fn get_bind_device(is_ipv4: bool) -> Option { + let v = Self::get_option(keys::OPTION_BIND_INTERFACE); + if v.is_empty() || v.parse::().is_ok() { + return None; + } + if matches!(Self::bind_decision(is_ipv4), BindDecision::Any) { + return None; + } + Some(system_device_name(&v)) + } + pub fn get_rendezvous_server() -> String { let mut rendezvous_server = EXE_RENDEZVOUS_SERVER.read().unwrap().clone(); if rendezvous_server.is_empty() { @@ -2847,6 +2896,321 @@ pub fn allow_insecure_tls_fallback() -> bool { option2bool(option, &Config::get_option(option)) } +// true if `ip` is currently on a local interface (used to fall back when a +// configured source ip is gone, e.g. vpn down or dhcp change) +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn local_ip_exists(ip: &IpAddr) -> bool { + netif::get_interfaces().iter().any(|i| match ip { + IpAddr::V4(v4) => i.ipv4.iter().any(|n| &n.addr() == v4), + IpAddr::V6(v6) => i.ipv6.iter().any(|n| &n.addr() == v6), + }) +} + +// no interface enumeration on mobile (default_net fork only has get_mac), so +// trust the configured ip +#[cfg(any(target_os = "android", target_os = "ios"))] +fn local_ip_exists(_ip: &IpAddr) -> bool { + true +} + +// first usable ipv4/ipv6 addr of the interface, matched by name or friendly name +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn interface_source_ip(name: &str, is_ipv4: bool) -> Option { + let iface = netif::get_interfaces() + .into_iter() + .find(|i| i.name == name || i.friendly_name.as_deref() == Some(name))?; + if is_ipv4 { + iface.ipv4.first().map(|n| IpAddr::V4(n.addr())) + } else { + // skip link-local (fe80::/10): bound as a source without a scope id it + // cannot reach off-link peers. Some interfaces (docker0, bridges) have + // nothing else, so fall back to it rather than refusing to bind. + iface + .ipv6 + .iter() + .map(|n| n.addr()) + .find(|a| !is_link_local_v6(a)) + .or_else(|| iface.ipv6.first().map(|n| n.addr())) + .map(IpAddr::V6) + } +} + +// Ipv6Addr::is_unicast_link_local is still unstable +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn is_link_local_v6(a: &Ipv6Addr) -> bool { + a.segments()[0] & 0xffc0 == 0xfe80 +} + +// no enumeration on mobile (the default_net fork only has get_mac), so an +// interface name cannot be resolved to a source ip there. It can still be +// pinned with SO_BINDTODEVICE, which is what PinOnly is for. +#[cfg(any(target_os = "android", target_os = "ios"))] +fn interface_source_ip(_name: &str, _is_ipv4: bool) -> Option { + None +} + +// whether an unresolvable interface name can still be honoured by pinning the +// device instead of setting a source address +const CAN_PIN_WITHOUT_SOURCE_IP: bool = cfg!(any(target_os = "android", target_os = "ios")); + +// What a socket should bind to, split out from the accessors so the decision is +// testable without real interfaces. +#[derive(Debug, PartialEq, Eq)] +enum BindDecision { + // nothing configured, or the target is unusable for this family: os picks + Any, + // use this local address + Use(IpAddr), + // the interface cannot be resolved to a source address on this platform, + // but the socket can still be pinned to the device (see apply_bind_device) + PinOnly, +} + +// `value` is either an ip address to use as the source, or an interface name -- +// same rule as libtorrent's outgoing_interfaces. An unusable value always falls +// back to letting the os choose. +fn decide_bind( + value: &str, + is_ipv4: bool, + ip_present: impl Fn(&IpAddr) -> bool, + iface_ip: impl Fn(&str, bool) -> Option, +) -> BindDecision { + decide_bind_with( + value, + is_ipv4, + ip_present, + iface_ip, + CAN_PIN_WITHOUT_SOURCE_IP, + ) +} + +fn decide_bind_with( + value: &str, + is_ipv4: bool, + ip_present: impl Fn(&IpAddr) -> bool, + iface_ip: impl Fn(&str, bool) -> Option, + can_pin_without_source_ip: bool, +) -> BindDecision { + if value.is_empty() { + return BindDecision::Any; + } + let Ok(ip) = value.parse::() else { + // not an address, so it names an interface + return match iface_ip(value, is_ipv4) { + Some(ip) => BindDecision::Use(ip), + // where addresses cannot be enumerated we cannot tell a missing + // interface from an address-less one, so leave the verdict to the + // device pinning, which fails at bind time for a name that is gone + None if can_pin_without_source_ip => BindDecision::PinOnly, + None => BindDecision::Any, + }; + }; + if ip.is_ipv4() != is_ipv4 { + // wrong family for this target; an ipv4 binding doesn't constrain ipv6 + return BindDecision::Any; + } + if ip_present(&ip) { + BindDecision::Use(ip) + } else { + BindDecision::Any + } +} + +// A configured value may be a friendly name (macOS "Wi-Fi", Windows "Ethernet +// 2"); SO_BINDTODEVICE and if_nametoindex only accept the system device name, +// so resolve it here rather than at each call site. +#[cfg(not(any(target_os = "android", target_os = "ios")))] +fn system_device_name(value: &str) -> String { + netif::get_interfaces() + .into_iter() + .find(|i| i.name == value || i.friendly_name.as_deref() == Some(value)) + .map(|i| i.name) + .unwrap_or_else(|| value.to_owned()) +} + +// no enumeration on mobile; the configured value is the device name there +#[cfg(any(target_os = "android", target_os = "ios"))] +fn system_device_name(value: &str) -> String { + value.to_owned() +} + +// interface name or friendly name -> kernel index, for the windows bind option +#[cfg(target_os = "windows")] +fn interface_index(name: &str, is_ipv4: bool) -> Option { + let iface = netif::get_interfaces() + .into_iter() + .find(|i| i.name == name || i.friendly_name.as_deref() == Some(name))?; + if is_ipv4 { + Some(iface.index) + } else { + // Interface.index is Windows' IPv4 IfIndex; IPV6_UNICAST_IF wants the + // separate Ipv6IfIndex, which netdev surfaces as the ipv6 scope id. + iface.ipv6_scope_ids.first().copied() + } +} + +// pin egress to an interface regardless of the routing table, to force traffic +// onto or off a (full-tunnel) vpn. per-os socket option: +// linux/android: SO_BINDTODEVICE (by name) +// macos/ios: IP_BOUND_IF / IPV6_BOUND_IF (by index) +// windows: IP_UNICAST_IF / IPV6_UNICAST_IF (by index) +// err is logged and ignored: an unusable interface falls back to the os choice. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub fn bind_socket_to_interface( + fd: std::os::unix::io::RawFd, + device: &str, + _is_ipv4: bool, +) -> std::io::Result<()> { + let cstr = std::ffi::CString::new(device).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid device name") + })?; + let ret = unsafe { + libc::setsockopt( + fd, + libc::SOL_SOCKET, + libc::SO_BINDTODEVICE, + cstr.as_ptr() as *const libc::c_void, + (device.len() + 1) as libc::socklen_t, + ) + }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + log::debug!("SO_BINDTODEVICE({device}) failed: {err}"); + return Err(err); + } + Ok(()) +} + +#[cfg(any(target_os = "macos", target_os = "ios"))] +pub fn bind_socket_to_interface( + fd: std::os::unix::io::RawFd, + device: &str, + is_ipv4: bool, +) -> std::io::Result<()> { + let cstr = std::ffi::CString::new(device).map_err(|_| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid device name") + })?; + let index = unsafe { libc::if_nametoindex(cstr.as_ptr()) }; + if index == 0 { + let err = std::io::Error::last_os_error(); + log::debug!("if_nametoindex({device}) failed: {err}"); + return Err(err); + } + let (level, optname) = if is_ipv4 { + (libc::IPPROTO_IP, libc::IP_BOUND_IF) + } else { + (libc::IPPROTO_IPV6, libc::IPV6_BOUND_IF) + }; + let index: libc::c_uint = index; + let ret = unsafe { + libc::setsockopt( + fd, + level, + optname, + &index as *const libc::c_uint as *const libc::c_void, + std::mem::size_of::() as libc::socklen_t, + ) + }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + log::debug!("IP_BOUND_IF({device}) failed: {err}"); + return Err(err); + } + Ok(()) +} + +#[cfg(target_os = "windows")] +pub fn bind_socket_to_interface( + sock: std::os::windows::io::RawSocket, + device: &str, + is_ipv4: bool, +) -> std::io::Result<()> { + use winapi::um::winsock2::{setsockopt, SOCKET}; + // from the windows sdk; winapi 0.3 doesn't expose IP_UNICAST_IF / IPPROTO_IPV6 + const IPPROTO_IP: i32 = 0; + const IPPROTO_IPV6: i32 = 41; + const IP_UNICAST_IF: i32 = 31; + const IPV6_UNICAST_IF: i32 = 31; + + let index = interface_index(device, is_ipv4).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::NotFound, + format!("interface {device} not found"), + ) + })?; + // ipv4 wants the index in network byte order, ipv6 in host order + let (level, optname, value) = if is_ipv4 { + (IPPROTO_IP, IP_UNICAST_IF, index.to_be()) + } else { + (IPPROTO_IPV6, IPV6_UNICAST_IF, index) + }; + let ret = unsafe { + setsockopt( + sock as SOCKET, + level, + optname, + &value as *const u32 as *const i8, + std::mem::size_of::() as i32, + ) + }; + if ret != 0 { + let err = std::io::Error::last_os_error(); + log::debug!("IP_UNICAST_IF({device}) failed: {err}"); + return Err(err); + } + Ok(()) +} + +/// No-op on platforms without a per-socket interface option; source-IP binding +/// still applies, which is enough to select the egress interface there. +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "windows" +)))] +pub fn bind_socket_to_interface( + _fd: std::os::raw::c_int, + _device: &str, + _is_ipv4: bool, +) -> std::io::Result<()> { + Ok(()) +} + +/// Pin `socket` to the configured interface, if one is configured by name. +/// A failure here is not fatal: the binding falls back to letting the os pick +/// an interface, which is what an unusable bind-interface value does elsewhere. +#[cfg(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios" +))] +pub fn apply_bind_device(socket: &S, is_ipv4: bool) { + let Some(device) = Config::get_bind_device(is_ipv4) else { + return; + }; + let _ = bind_socket_to_interface(socket.as_raw_fd(), &device, is_ipv4); +} + +#[cfg(target_os = "windows")] +pub fn apply_bind_device(socket: &S, is_ipv4: bool) { + let Some(device) = Config::get_bind_device(is_ipv4) else { + return; + }; + let _ = bind_socket_to_interface(socket.as_raw_socket(), &device, is_ipv4); +} + +#[cfg(not(any( + target_os = "linux", + target_os = "android", + target_os = "macos", + target_os = "ios", + target_os = "windows" +)))] +pub fn apply_bind_device(_socket: &S, _is_ipv4: bool) {} + pub mod keys { pub const OPTION_VIEW_ONLY: &str = "view_only"; pub const OPTION_SHOW_MONITORS_TOOLBAR: &str = "show_monitors_toolbar"; @@ -2968,6 +3332,9 @@ pub mod keys { pub const OPTION_FILE_TRANSFER_MAX_FILES: &str = "file-transfer-max-files"; pub const OPTION_DISABLE_UDP: &str = "disable-udp"; pub const OPTION_ALLOW_INSECURE_TLS_FALLBACK: &str = "allow-insecure-tls-fallback"; + // bind incoming and outgoing sockets to a specific interface: + // a source ip address, or the name of the interface; empty = off + pub const OPTION_BIND_INTERFACE: &str = "bind-interface"; pub const OPTION_SHOW_VIRTUAL_MOUSE: &str = "show-virtual-mouse"; // joystick is the virtual mouse. // So `OPTION_SHOW_VIRTUAL_MOUSE` should also be set if `OPTION_SHOW_VIRTUAL_JOYSTICK` is set. @@ -3289,6 +3656,86 @@ mod tests { static CONFIG_STATE_TEST_LOCK: Mutex<()> = Mutex::new(()); + // ----- bind-to-interface decision logic (no real interfaces needed) ----- + + fn ip(s: &str) -> IpAddr { + s.parse().unwrap() + } + + #[test] + fn bind_empty_yields_no_binding() { + let no_ip = |_: &IpAddr| false; + let no_if = |_: &str, _: bool| None; + assert_eq!(decide_bind("", true, no_ip, no_if), BindDecision::Any); + } + + #[test] + fn bind_ip_present_is_used() { + let present = |a: &IpAddr| a == &ip("192.168.1.10"); + let no_if = |_: &str, _: bool| None; + assert_eq!( + decide_bind("192.168.1.10", true, present, no_if), + BindDecision::Use(ip("192.168.1.10")) + ); + } + + #[test] + fn bind_ip_absent_falls_back() { + let absent = |_: &IpAddr| false; + let no_if = |_: &str, _: bool| None; + assert_eq!( + decide_bind("10.0.0.5", true, absent, no_if), + BindDecision::Any + ); + } + + #[test] + fn bind_ip_family_mismatch_falls_back() { + let present = |_: &IpAddr| true; + let no_if = |_: &str, _: bool| None; + // configured IPv4 but the target is IPv6: the binding does not apply + assert_eq!( + decide_bind("10.0.0.5", false, present, no_if), + BindDecision::Any + ); + } + + #[test] + fn bind_non_address_is_an_interface_name() { + let no_ip = |_: &IpAddr| false; + let resolve = |name: &str, is_ipv4: bool| { + if name == "eth0" && is_ipv4 { + Some(ip("172.16.0.2")) + } else { + None + } + }; + assert_eq!( + decide_bind("eth0", true, no_ip, resolve), + BindDecision::Use(ip("172.16.0.2")) + ); + // unknown interface, or one with no address of this family -> fall back + assert_eq!(decide_bind("wg0", true, no_ip, resolve), BindDecision::Any); + assert_eq!(decide_bind("eth0", false, no_ip, resolve), BindDecision::Any); + } + + #[test] + fn interface_name_pins_the_device_where_addresses_cannot_be_enumerated() { + let no_ip = |_: &IpAddr| false; + let no_if = |_: &str, _: bool| None; + // mobile: an interface name resolves to no address, but SO_BINDTODEVICE + // still applies, so the name must not be discarded + assert_eq!( + decide_bind_with("wlan0", true, no_ip, no_if, true), + BindDecision::PinOnly + ); + // desktop: addresses are enumerable, so an unresolvable name falls back + assert_eq!( + decide_bind_with("wlan0", true, no_ip, no_if, false), + BindDecision::Any + ); + } + struct ConfigStateTestGuard { original_config: Config, original_hard_settings: HashMap, diff --git a/src/socket_client.rs b/src/socket_client.rs index 9178b74b5d..7db8cd64e6 100644 --- a/src/socket_client.rs +++ b/src/socket_client.rs @@ -204,9 +204,20 @@ pub fn ipv4_to_ipv6(addr: String, ipv4: bool) -> String { } async fn test_target(target: &str) -> ResultType { - if let Ok(Ok(s)) = super::timeout(1000, tokio::net::TcpStream::connect(target)).await { - if let Ok(addr) = s.peer_addr() { - return Ok(addr); + // probe through a socket bound like the real connection will be: an unbound + // connect here leaves via the default route, which on a full-tunnel VPN is + // exactly the interface a configured bind-interface is meant to avoid. + if let Ok(addrs) = tokio::net::lookup_host(target).await { + for addr in addrs { + let local = Config::get_any_listen_addr(addr.is_ipv4()); + let Ok(socket) = crate::tcp::new_socket_pinned(local, true, true) else { + continue; + }; + if let Ok(Ok(s)) = super::timeout(1000, socket.connect(addr)).await { + if let Ok(peer) = s.peer_addr() { + return Ok(peer); + } + } } } tokio::net::lookup_host(target) @@ -219,7 +230,9 @@ async fn test_target(target: &str) -> ResultType { pub async fn new_direct_udp_for(target: &str) -> ResultType<(Arc, SocketAddr)> { let peer_addr = test_target(target).await?; let local_addr = Config::get_any_listen_addr(peer_addr.is_ipv4()); - let socket = UdpSocket::bind(local_addr).await?; + // through udp::new_socket, so the device pinning applies here too; a plain + // UdpSocket::bind only gets the source address, not the interface + let socket = UdpSocket::from_std(crate::udp::new_socket_pinned(local_addr, false, 0, true)?.into_udp_socket())?; Ok((Arc::new(socket), peer_addr)) } diff --git a/src/tcp.rs b/src/tcp.rs index 2296edb1d3..2fa17e090c 100644 --- a/src/tcp.rs +++ b/src/tcp.rs @@ -63,6 +63,18 @@ impl DerefMut for DynTcpStream { } pub(crate) fn new_socket(addr: std::net::SocketAddr, reuse: bool) -> Result { + new_socket_pinned(addr, reuse, false) +} + +// `pin_device` must only be set for sockets whose address came from the +// interface binding. new_listener() binds caller-supplied addresses -- the +// port-forward/RDP listeners use 127.0.0.1 -- and SO_BINDTODEVICE on those +// would filter out traffic arriving on loopback, making them unreachable. +pub(crate) fn new_socket_pinned( + addr: std::net::SocketAddr, + reuse: bool, + pin_device: bool, +) -> Result { let socket = match addr { std::net::SocketAddr::V4(..) => TcpSocket::new_v4()?, std::net::SocketAddr::V6(..) => TcpSocket::new_v6()?, @@ -76,6 +88,11 @@ pub(crate) fn new_socket(addr: std::net::SocketAddr, reuse: bool) -> Result ResultType { for remote_addr in lookup_host(&remote_addr).await? { - let local = if let Some(addr) = local_addr { - addr + // only a local address we derived from bind-interface may be pinned + // to the device; a caller-supplied one (e.g. 127.0.0.1) would then + // be filtered onto the wrong link + let (local, pin) = if let Some(addr) = local_addr { + (addr, false) } else { - crate::config::Config::get_any_listen_addr(remote_addr.is_ipv4()) + ( + crate::config::Config::get_any_listen_addr(remote_addr.is_ipv4()), + true, + ) }; - if let Ok(socket) = new_socket(local, true) { + if let Ok(socket) = new_socket_pinned(local, true, pin) { if let Ok(Ok(stream)) = super::timeout(ms_timeout, socket.connect(remote_addr)).await { @@ -224,6 +247,18 @@ pub async fn new_listener(addr: T, reuse: bool) -> ResultType< } pub async fn listen_any(port: u16) -> ResultType { + // ingress counterpart of the egress binding in new_socket: if bound to a + // specific interface/ip, listen there instead of on all interfaces, so the + // host stays reachable on that interface (e.g. the LAN) regardless of a VPN. + if let Some(ip) = crate::config::Config::get_bind_listen_ip(true) + .or_else(|| crate::config::Config::get_bind_listen_ip(false)) + { + // a listener bound to one address cannot be dual-stack: v4-mapped + // acceptance only works on the unspecified address. ipv4 first, that + // being what direct lan access uses in practice. + log::info!("listening on {ip}:{port} only, per the configured interface binding"); + return Ok(new_socket_pinned(SocketAddr::new(ip, port), true, true)?.listen(DEFAULT_BACKLOG)?); + } if let Ok(mut socket) = TcpSocket::new_v6() { #[cfg(unix)] { @@ -245,6 +280,10 @@ pub async fn listen_any(port: u16) -> ResultType { sock2.set_only_v6(false).ok(); socket = unsafe { TcpSocket::from_raw_socket(sock2.into_raw_socket()) }; } + // this path builds its own socket rather than going through new_socket, + // so pin the device here too: without it a PinOnly binding (an interface + // we cannot resolve to an address) would silently not apply to ingress + crate::config::apply_bind_device(&socket, false); if socket .bind(SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), port)) .is_ok() @@ -254,9 +293,10 @@ pub async fn listen_any(port: u16) -> ResultType { } } } - Ok(new_socket( + Ok(new_socket_pinned( SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), port), true, + true, )? .listen(DEFAULT_BACKLOG)?) } diff --git a/src/udp.rs b/src/udp.rs index fbdf332f30..639661ad89 100644 --- a/src/udp.rs +++ b/src/udp.rs @@ -14,7 +14,23 @@ pub enum FramedSocket { ProxySocks(Socks5UdpFramed), } -fn new_socket(addr: SocketAddr, reuse: bool, buf_size: usize) -> Result { +pub(crate) fn new_socket( + addr: SocketAddr, + reuse: bool, + buf_size: usize, +) -> Result { + new_socket_pinned(addr, reuse, buf_size, false) +} + +// `pin_device` must only be set for addresses derived from bind-interface; +// FramedSocket::new/new_reuse take caller-supplied addresses, and pinning those +// to the configured device can filter out traffic that used to arrive fine. +pub(crate) fn new_socket_pinned( + addr: SocketAddr, + reuse: bool, + buf_size: usize, + pin_device: bool, +) -> Result { let socket = match addr { SocketAddr::V4(..) => Socket::new(Domain::ipv4(), Type::dgram(), None), SocketAddr::V6(..) => Socket::new(Domain::ipv6(), Type::dgram(), None), @@ -41,6 +57,10 @@ fn new_socket(addr: SocketAddr, reuse: bool, buf_size: usize) -> Result 0 { socket.set_only_v6(false).ok(); } + // same as tcp.rs: pin the device when an interface is named + if pin_device { + crate::config::apply_bind_device(&socket, addr.is_ipv4()); + } socket.bind(&addr.into())?; Ok(socket) } diff --git a/src/websocket.rs b/src/websocket.rs index 7bf2108409..622dde33a2 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -19,10 +19,13 @@ use std::{ sync::Arc, time::Duration, }; -use tokio::{net::TcpStream, time::timeout}; +use tokio::{ + net::TcpStream, + time::{timeout, timeout_at, Instant}, +}; use tokio_native_tls::native_tls::TlsConnector; use tokio_tungstenite::{ - connect_async_tls_with_config, tungstenite::protocol::Message as WsMessage, Connector, + client_async_tls_with_config, tungstenite::protocol::Message as WsMessage, Connector, MaybeTlsStream, WebSocketStream, }; use tungstenite::client::IntoClientRequest; @@ -65,6 +68,32 @@ impl WsFramedStream { } } + // dial the websocket peer through a socket bound to the configured + // interface. tungstenite's own connect_async opens an unbound socket, which + // would leave this transport ignoring bind-interface entirely. + async fn connect_bound_tcp(request: &tungstenite::handshake::client::Request) -> ResultType { + let uri = request.uri(); + let Some(host) = uri.host() else { + bail!("websocket url has no host: {uri}"); + }; + let port = uri.port_u16().unwrap_or(match uri.scheme_str() { + Some("wss") | Some("https") => 443, + _ => 80, + }); + let mut last_err = None; + for addr in tokio::net::lookup_host((host, port)).await? { + let local = Config::get_any_listen_addr(addr.is_ipv4()); + match crate::tcp::new_socket_pinned(local, false, true) { + Ok(socket) => match socket.connect(addr).await { + Ok(stream) => return Ok(stream), + Err(e) => last_err = Some(anyhow::Error::from(e)), + }, + Err(e) => last_err = Some(anyhow::Error::from(e)), + } + } + Err(last_err.unwrap_or_else(|| anyhow::anyhow!("failed to resolve {host}:{port}"))) + } + async fn connect( url: &str, ms_timeout: u64, @@ -96,15 +125,21 @@ impl WsFramedStream { original_danger_accept_invalid_certs: Option, ) -> ResultType>> { let ws_config = None; - let disable_nagle = false; let request = url .into_client_request() .map_err(|e| Error::new(ErrorKind::Other, e))?; let connector = Self::get_connector(&tls_type, danger_accept_invalid_cert.unwrap_or(false))?; - match timeout( - Duration::from_millis(ms_timeout), - connect_async_tls_with_config(request, ws_config, disable_nagle, connector), + // build the tcp connection ourselves instead of letting tungstenite dial, + // so this transport honours bind-interface like the plain tcp one does. + // dial and handshake share one deadline, as they did when tungstenite + // did both: a budget per phase would let a single attempt take twice + // ms_timeout, and the tls fallback below retries up to three times. + let deadline = Instant::now() + Duration::from_millis(ms_timeout); + let stream = timeout_at(deadline, Self::connect_bound_tcp(&request)).await??; + match timeout_at( + deadline, + client_async_tls_with_config(request, stream, ws_config, connector), ) .await? { diff --git a/tests/bind_interface.rs b/tests/bind_interface.rs new file mode 100644 index 0000000000..f5691aeaf6 --- /dev/null +++ b/tests/bind_interface.rs @@ -0,0 +1,188 @@ +//! socket binding over loopback, so no root needed. proves the source ip is +//! applied to the socket, that an unusable value falls back instead of cutting +//! the connection, and that the websocket transport is bound too. + +use hbb_common::{ + config::{keys, Config}, + socket_client, tcp, +}; +use std::net::TcpListener; +use std::sync::Mutex; +use std::time::Duration; + +// Config is global process state; serialize and restore so we never leave the +// host's real config modified. +static LOCK: Mutex<()> = Mutex::new(()); + +// A panicking test poisons the mutex; without recovery every later test then +// fails on the lock instead of running, hiding which one actually broke. +fn lock() -> std::sync::MutexGuard<'static, ()> { + LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +struct BindRestore(String); + +impl BindRestore { + fn save() -> Self { + Self(Config::get_option(keys::OPTION_BIND_INTERFACE)) + } +} + +impl Drop for BindRestore { + fn drop(&mut self) { + set_bind(&self.0); + } +} + +fn set_bind(interface: &str) { + Config::set_option(keys::OPTION_BIND_INTERFACE.to_owned(), interface.to_owned()); +} + +#[tokio::test] +async fn bind_source_ip_end_to_end() { + let _guard = lock(); + let _restore = BindRestore::save(); + + // A present local address (loopback always exists) is selected. + set_bind("127.0.0.1"); + assert_eq!( + Config::get_bind_source_ip(true).map(|ip| ip.to_string()), + Some("127.0.0.1".to_owned()) + ); + + // Anything unusable falls back to letting the OS choose. + set_bind("203.0.113.7"); + assert_eq!(Config::get_bind_source_ip(true), None); + set_bind("definitely-not-an-interface"); + assert_eq!(Config::get_bind_source_ip(true), None); + // ... and then must not pin a device either, or the socket would fail + assert_eq!(Config::get_bind_device(true), None); + + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let target = format!("127.0.0.1:{port}"); + + // Bound to a present local IP: connects, and the socket carries it. + set_bind("127.0.0.1"); + // connect_tcp_local, not connect_tcp: the latter diverts to the websocket + // path when allow-websocket is set in the host config, which would test + // nothing about socket binding and make the result depend on the machine. + let stream = socket_client::connect_tcp_local(target.clone(), None, 3000) + .await + .expect("bind to present local IP should connect"); + assert_eq!( + stream.local_addr().ip().to_string(), + "127.0.0.1", + "outgoing socket must be bound to the configured source IP" + ); + drop(stream); + + // Unusable values fall back and still connect -- never fail closed. + for v in ["203.0.113.7", "definitely-not-an-interface", ""] { + set_bind(v); + socket_client::connect_tcp_local(target.clone(), None, 3000) + .await + .unwrap_or_else(|e| panic!("bind-interface {v:?} should fall back and connect: {e}")); + } +} + +// The websocket transport used to dial via tungstenite's own connect_async, +// which opens an unbound socket -- so with allow-websocket set, the binding did +// not apply to outgoing connections at all. Over loopback we can only show it +// still connects: with strict gone, an address that is not enumerated falls +// back, so there is no way to force a distinguishable source here. The source +// address is proven against real interfaces in the network-namespace run. +#[tokio::test] +async fn websocket_transport_still_connects_when_bound() { + let _guard = lock(); + let _restore = BindRestore::save(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + set_bind("127.0.0.1"); + let url = format!("ws://127.0.0.1:{port}"); + let dial = tokio::spawn(async move { + let _ = hbb_common::websocket::WsFramedStream::new(url, None, None, 3000).await; + }); + + let (_sock, peer) = tokio::time::timeout(Duration::from_secs(5), listener.accept()) + .await + .expect("websocket dial did not connect in time") + .expect("accept failed"); + assert_eq!(peer.ip().to_string(), "127.0.0.1"); + dial.abort(); +} + +// new_listener() binds caller-supplied addresses; the port-forward and RDP +// listeners use 127.0.0.1. Pinning those to a configured interface would filter +// out traffic arriving on loopback and make them unreachable. +#[tokio::test] +async fn explicit_listeners_are_not_pinned() { + let _guard = lock(); + let _restore = BindRestore::save(); + + set_bind("127.0.0.2"); + let l = tcp::new_listener("127.0.0.1:0", true) + .await + .expect("explicit loopback listener must still bind"); + let port = l.local_addr().unwrap().port(); + + set_bind(""); + let s = socket_client::connect_tcp_local(format!("127.0.0.1:{port}"), None, 3000).await; + assert!(s.is_ok(), "explicit loopback listener must stay reachable"); +} + +#[tokio::test] +async fn listener_falls_back_instead_of_failing() { + let _guard = lock(); + let _restore = BindRestore::save(); + + set_bind(""); + let l = tcp::listen_any(0).await.expect("default should listen"); + assert!( + l.local_addr().unwrap().ip().is_unspecified(), + "unbound listener must stay on the wildcard address" + ); + drop(l); + + set_bind("127.0.0.1"); + let l = tcp::listen_any(0).await.expect("present address should listen"); + assert_eq!(l.local_addr().unwrap().ip().to_string(), "127.0.0.1"); + drop(l); + + // unusable: listen on all interfaces rather than refusing, and in + // particular not on loopback -- that would be "listening" while + // unreachable, which is the regression this whole path exists to avoid + set_bind("definitely-not-an-interface"); + let l = tcp::listen_any(0) + .await + .expect("an unusable binding must fall back to all interfaces"); + assert!( + l.local_addr().unwrap().ip().is_unspecified(), + "fallback listener must bind the wildcard address, not loopback" + ); +} + +// FramedStream::new and FramedSocket::new take caller-supplied local addresses; +// those must never be pinned to the configured device, or a connection that +// used to work (e.g. over loopback) gets filtered onto the wrong link. +#[tokio::test] +async fn caller_supplied_local_address_is_not_pinned() { + let _guard = lock(); + let _restore = BindRestore::save(); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + + // an interface that exists but is not the loopback path to the listener + let device = hbb_common::config::Config::get_option(keys::OPTION_BIND_INTERFACE); + drop(device); + set_bind("lo"); + + let local: std::net::SocketAddr = "127.0.0.1:0".parse().unwrap(); + let stream = socket_client::connect_tcp_local(format!("127.0.0.1:{port}"), Some(local), 3000) + .await + .expect("a caller-supplied local address must still connect"); + assert_eq!(stream.local_addr().ip().to_string(), "127.0.0.1"); +}