diff --git a/crates/slipstream-client/src/android.rs b/crates/slipstream-client/src/android.rs new file mode 100644 index 00000000..ee87220e --- /dev/null +++ b/crates/slipstream-client/src/android.rs @@ -0,0 +1,125 @@ +//! Android VPN socket protection for SIP003 plugins. +//! +//! When running as a Shadowsocks plugin in Android VPN mode, the plugin's UDP +//! sockets must be "protected" to bypass VPN routing. Otherwise, the plugin's +//! traffic would be routed back through the VPN tunnel, creating an infinite loop. +//! +//! The protection mechanism uses a Unix domain socket at `./protect_path` to +//! send the socket file descriptor to the Android VPN service, which then calls +//! `VpnService.protect()` on it. + +use std::io::{self, Read}; +use std::os::unix::io::{AsRawFd, RawFd}; +use std::os::unix::net::UnixStream; +use std::time::Duration; + +/// Default path to the protect callback Unix socket (relative to working directory). +const PROTECT_PATH: &str = "protect_path"; + +/// Timeout for socket protection operations. +const PROTECT_TIMEOUT: Duration = Duration::from_secs(3); + +/// Protects a socket from VPN routing by sending its file descriptor to the +/// Android VPN service via a Unix domain socket. +/// +/// Returns `Ok(true)` if protection succeeded, `Ok(false)` if the protect_path +/// doesn't exist (not in VPN mode), or `Err` on communication failure. +pub fn protect_socket(fd: RawFd) -> io::Result { + // Try to connect to the protect_path Unix socket + let stream = match UnixStream::connect(PROTECT_PATH) { + Ok(s) => s, + Err(e) if e.kind() == io::ErrorKind::NotFound => { + // protect_path doesn't exist - not running in VPN mode + return Ok(false); + } + Err(e) if e.kind() == io::ErrorKind::ConnectionRefused => { + // VPN service not ready + return Ok(false); + } + Err(e) => return Err(e), + }; + + // Set timeouts + stream.set_read_timeout(Some(PROTECT_TIMEOUT))?; + stream.set_write_timeout(Some(PROTECT_TIMEOUT))?; + + // Send the file descriptor using SCM_RIGHTS ancillary message + send_fd(&stream, fd)?; + + // Wait for confirmation (single byte response) + let mut response = [0u8; 1]; + let mut stream_ref = &stream; + match stream_ref.read_exact(&mut response) { + Ok(()) => Ok(response[0] != 0), + Err(e) => Err(e), + } +} + +/// Sends a file descriptor over a Unix socket using SCM_RIGHTS. +fn send_fd(stream: &UnixStream, fd: RawFd) -> io::Result<()> { + use libc::{c_void, cmsghdr, iovec, msghdr, CMSG_DATA, CMSG_FIRSTHDR, CMSG_LEN, CMSG_SPACE}; + use std::mem; + use std::ptr; + + // Dummy data to send (required for sendmsg) + let dummy: [u8; 1] = [0]; + let mut iov = iovec { + iov_base: dummy.as_ptr() as *mut c_void, + iov_len: dummy.len(), + }; + + // Calculate control message buffer size + let cmsg_space = unsafe { CMSG_SPACE(mem::size_of::() as u32) } as usize; + let mut cmsg_buf = vec![0u8; cmsg_space]; + + // Build the message header + let mut msg: msghdr = unsafe { mem::zeroed() }; + msg.msg_iov = &mut iov; + msg.msg_iovlen = 1; + msg.msg_control = cmsg_buf.as_mut_ptr() as *mut c_void; + msg.msg_controllen = cmsg_space; + + // Fill in the control message header + let cmsg: *mut cmsghdr = unsafe { CMSG_FIRSTHDR(&msg) }; + if cmsg.is_null() { + return Err(io::Error::new( + io::ErrorKind::Other, + "Failed to get control message header", + )); + } + + unsafe { + (*cmsg).cmsg_level = libc::SOL_SOCKET; + (*cmsg).cmsg_type = libc::SCM_RIGHTS; + (*cmsg).cmsg_len = CMSG_LEN(mem::size_of::() as u32) as usize; + + // Copy the file descriptor into the control message data + let fd_ptr = CMSG_DATA(cmsg) as *mut RawFd; + ptr::write(fd_ptr, fd); + } + + // Send the message + let sock_fd = stream.as_raw_fd(); + let result = unsafe { libc::sendmsg(sock_fd, &msg, 0) }; + + if result < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn protect_socket_missing_path_returns_false() { + // When protect_path doesn't exist, should return Ok(false) + let socket = std::net::UdpSocket::bind("127.0.0.1:0").unwrap(); + let fd = socket.as_raw_fd(); + // This will return false since protect_path doesn't exist in test env + let result = protect_socket(fd); + assert!(result.is_ok()); + } +} diff --git a/crates/slipstream-client/src/main.rs b/crates/slipstream-client/src/main.rs index ebfe92e9..c5572078 100644 --- a/crates/slipstream-client/src/main.rs +++ b/crates/slipstream-client/src/main.rs @@ -1,3 +1,5 @@ +#[cfg(target_os = "android")] +mod android; mod dns; mod error; mod pacing; @@ -192,6 +194,11 @@ fn main() { keep_alive_override.unwrap_or(args.keep_alive_interval) }; + let android_vpn = has_option(&sip003_env.plugin_options, "__android_vpn"); + if android_vpn { + tracing::info!("Android VPN mode detected; socket protection enabled"); + } + let config = ClientConfig { tcp_listen_host: &tcp_listen_host, tcp_listen_port, @@ -203,6 +210,7 @@ fn main() { keep_alive_interval: keep_alive_interval as usize, debug_poll: args.debug_poll, debug_streams: args.debug_streams, + android_vpn, }; let runtime = Builder::new_current_thread() @@ -387,6 +395,10 @@ fn last_option_value(options: &[sip003::Sip003Option], key: &str) -> Option bool { + options.iter().any(|option| option.key == key) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/slipstream-client/src/runtime.rs b/crates/slipstream-client/src/runtime.rs index cdc237d7..579d49a7 100644 --- a/crates/slipstream-client/src/runtime.rs +++ b/crates/slipstream-client/src/runtime.rs @@ -70,7 +70,7 @@ fn drain_disconnected_commands(command_rx: &mut mpsc::UnboundedReceiver pub async fn run_client(config: &ClientConfig<'_>) -> Result { let domain_len = config.domain.len(); let mtu = compute_mtu(domain_len)?; - let udp = bind_udp_socket().await?; + let udp = bind_udp_socket(config.android_vpn).await?; let (command_tx, mut command_rx) = mpsc::unbounded_channel(); let data_notify = Arc::new(Notify::new()); diff --git a/crates/slipstream-client/src/runtime/setup.rs b/crates/slipstream-client/src/runtime/setup.rs index 5cbf2fd5..667a63cf 100644 --- a/crates/slipstream-client/src/runtime/setup.rs +++ b/crates/slipstream-client/src/runtime/setup.rs @@ -1,6 +1,8 @@ use crate::error::ClientError; use socket2::{Domain, Protocol, SockAddr, Socket, Type}; use std::net::{Ipv6Addr, SocketAddr, SocketAddrV6}; +#[cfg(target_os = "android")] +use std::os::unix::io::AsRawFd; use tokio::net::{lookup_host, TcpListener as TokioTcpListener, UdpSocket as TokioUdpSocket}; use tracing::warn; @@ -19,9 +21,9 @@ pub(crate) fn compute_mtu(domain_len: usize) -> Result { Ok(mtu) } -pub(crate) async fn bind_udp_socket() -> Result { +pub(crate) async fn bind_udp_socket(android_vpn: bool) -> Result { let bind_addr = SocketAddr::V6(SocketAddrV6::new(Ipv6Addr::UNSPECIFIED, 0, 0, 0)); - bind_udp_socket_addr(bind_addr) + bind_udp_socket_addr(bind_addr, android_vpn) } pub(crate) async fn bind_tcp_listener( @@ -73,7 +75,7 @@ fn bind_tcp_listener_addr(addr: SocketAddr) -> Result Result { +fn bind_udp_socket_addr(addr: SocketAddr, android_vpn: bool) -> Result { let domain = match addr { SocketAddr::V4(_) => Domain::IPV4, SocketAddr::V6(_) => Domain::IPV6, @@ -89,6 +91,29 @@ fn bind_udp_socket_addr(addr: SocketAddr) -> Result } let sock_addr = SockAddr::from(addr); socket.bind(&sock_addr).map_err(map_io)?; + + // Protect socket from VPN routing on Android + #[cfg(target_os = "android")] + if android_vpn { + let fd = socket.as_raw_fd(); + match crate::android::protect_socket(fd) { + Ok(true) => { + tracing::info!("UDP socket protected from VPN routing"); + } + Ok(false) => { + tracing::debug!("VPN protection not available (protect_path not found)"); + } + Err(err) => { + return Err(ClientError::new(format!( + "Failed to protect UDP socket: {}", + err + ))); + } + } + } + #[cfg(not(target_os = "android"))] + let _ = android_vpn; // Suppress unused warning on non-Android + socket.set_nonblocking(true).map_err(map_io)?; let std_socket: std::net::UdpSocket = socket.into(); TokioUdpSocket::from_std(std_socket).map_err(map_io) diff --git a/crates/slipstream-core/src/sip003.rs b/crates/slipstream-core/src/sip003.rs index 8c1355fd..2c9ecbe3 100644 --- a/crates/slipstream-core/src/sip003.rs +++ b/crates/slipstream-core/src/sip003.rs @@ -220,7 +220,8 @@ fn push_option( } fn allows_empty_value_key(key: &str) -> bool { - key.trim().eq_ignore_ascii_case("authoritative") + let key = key.trim(); + key.eq_ignore_ascii_case("authoritative") || key.eq_ignore_ascii_case("__android_vpn") } #[cfg(test)] diff --git a/crates/slipstream-ffi/src/lib.rs b/crates/slipstream-ffi/src/lib.rs index be3fe999..71a1e286 100644 --- a/crates/slipstream-ffi/src/lib.rs +++ b/crates/slipstream-ffi/src/lib.rs @@ -34,6 +34,7 @@ pub struct ClientConfig<'a> { pub keep_alive_interval: usize, pub debug_poll: bool, pub debug_streams: bool, + pub android_vpn: bool, } pub use runtime::{