From b2786a850cab0adc58e8cca6767fdf20e31ed20d Mon Sep 17 00:00:00 2001 From: Mobin Dariush <34644374+dalisyron@users.noreply.github.com> Date: Fri, 23 Jan 2026 11:30:21 -0800 Subject: [PATCH] Add 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. Changes: - Add android.rs module with protect_socket() using SCM_RIGHTS - Parse __android_vpn flag from SS_PLUGIN_OPTIONS - Add android_vpn field to ClientConfig - Call protect_socket() after binding UDP socket when in VPN mode --- crates/slipstream-client/src/android.rs | 125 ++++++++++++++++++ crates/slipstream-client/src/main.rs | 12 ++ crates/slipstream-client/src/runtime.rs | 2 +- crates/slipstream-client/src/runtime/setup.rs | 31 ++++- crates/slipstream-core/src/sip003.rs | 3 +- crates/slipstream-ffi/src/lib.rs | 1 + 6 files changed, 169 insertions(+), 5 deletions(-) create mode 100644 crates/slipstream-client/src/android.rs 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::{