Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 125 additions & 0 deletions crates/slipstream-client/src/android.rs
Original file line number Diff line number Diff line change
@@ -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<bool> {
// 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::<RawFd>() 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::<RawFd>() 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());
}
}
12 changes: 12 additions & 0 deletions crates/slipstream-client/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(target_os = "android")]
mod android;
mod dns;
mod error;
mod pacing;
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -387,6 +395,10 @@ fn last_option_value(options: &[sip003::Sip003Option], key: &str) -> Option<Stri
last
}

fn has_option(options: &[sip003::Sip003Option], key: &str) -> bool {
options.iter().any(|option| option.key == key)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
2 changes: 1 addition & 1 deletion crates/slipstream-client/src/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ fn drain_disconnected_commands(command_rx: &mut mpsc::UnboundedReceiver<Command>
pub async fn run_client(config: &ClientConfig<'_>) -> Result<i32, ClientError> {
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());
Expand Down
31 changes: 28 additions & 3 deletions crates/slipstream-client/src/runtime/setup.rs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -19,9 +21,9 @@ pub(crate) fn compute_mtu(domain_len: usize) -> Result<u32, ClientError> {
Ok(mtu)
}

pub(crate) async fn bind_udp_socket() -> Result<TokioUdpSocket, ClientError> {
pub(crate) async fn bind_udp_socket(android_vpn: bool) -> Result<TokioUdpSocket, ClientError> {
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(
Expand Down Expand Up @@ -73,7 +75,7 @@ fn bind_tcp_listener_addr(addr: SocketAddr) -> Result<TokioTcpListener, ClientEr
TokioTcpListener::from_std(std_listener).map_err(map_io)
}

fn bind_udp_socket_addr(addr: SocketAddr) -> Result<TokioUdpSocket, ClientError> {
fn bind_udp_socket_addr(addr: SocketAddr, android_vpn: bool) -> Result<TokioUdpSocket, ClientError> {
let domain = match addr {
SocketAddr::V4(_) => Domain::IPV4,
SocketAddr::V6(_) => Domain::IPV6,
Expand All @@ -89,6 +91,29 @@ fn bind_udp_socket_addr(addr: SocketAddr) -> Result<TokioUdpSocket, ClientError>
}
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)");
}
Comment on lines +103 to +105

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Abort when VPN protection is unavailable

In Android VPN mode, android_vpn is only set when the __android_vpn flag is present, but this branch treats protect_socket returning Ok(false) as a soft failure and continues. That happens when protect_path is missing or the VPN service refuses the connection; in that case the UDP socket remains unprotected and traffic will loop back through the VPN tunnel, breaking connectivity (the exact scenario the change is meant to prevent). Consider treating Ok(false) as an error (or retry) when android_vpn is true so the client doesn’t run in a guaranteed-failure state.

Useful? React with 👍 / 👎.

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)
Expand Down
3 changes: 2 additions & 1 deletion crates/slipstream-core/src/sip003.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Comment on lines 222 to 225
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A test case should be added for the __android_vpn option parsing, similar to the existing test for the authoritative option (test allows_authoritative_without_value). This would ensure the parsing logic works correctly for this new option and follows the established testing pattern in the codebase.

Copilot uses AI. Check for mistakes.

#[cfg(test)]
Expand Down
1 change: 1 addition & 0 deletions crates/slipstream-ffi/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down
Loading