diff --git a/Cargo.toml b/Cargo.toml index 11a49653a..fe25b0b3f 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 a3eba1215..6a3019536 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 9178b74b5..7db8cd64e 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 2296edb1d..2fa17e090 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 fbdf332f3..639661ad8 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 7bf210840..622dde33a 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 000000000..f5691aeaf --- /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"); +}