diff --git a/README.md b/README.md index 0829f55..7b49be6 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ through the phone automatically. ## Current Status This repository contains the production architecture, protocol specification, -Android project scaffold, Rust core scaffold, and the first USB-first engine -interfaces. It is not yet a complete packet-forwarding release. +Android project scaffold, Rust core scaffold, and a Linux-first USB gateway +prototype. The first shippable milestone is: @@ -21,6 +21,11 @@ The first shippable milestone is: - Desktop client foundation for Windows, Linux, and macOS. - Encrypted VSHP tunnel with QR/code pairing. +The current desktop binary provides USB sharing through the Android gateway. On +Linux it can create a real `vpnshare0` TUN interface and route normal OS/app +traffic through the phone with `sudo ./target/debug/vpnshare-desktop +system-gateway`. See [docs/desktop-client.md](docs/desktop-client.md). + ## Important Platform Constraint VPN Share uses companion clients on receiving devices. This is required because @@ -59,9 +64,6 @@ Android validation, once a healthy Gradle installation or wrapper is available: gradle :apps:android:app:assembleDebug ``` -The local environment used to create this scaffold had a broken system Gradle -native-platform installation, so Android compilation was not executed locally. - ## License Apache-2.0. See [LICENSE](LICENSE). diff --git a/apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt b/apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt index 58dc7b1..20e7cda 100644 --- a/apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt +++ b/apps/android/app/src/main/kotlin/org/vpnshare/app/MainActivity.kt @@ -10,6 +10,7 @@ import org.vpnshare.gateway.VpnShareGatewayService class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + maybeStartShare(intent) setContent { ShareScreen( @@ -28,4 +29,22 @@ class MainActivity : ComponentActivity() { ) } } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + maybeStartShare(intent) + } + + private fun maybeStartShare(intent: Intent?) { + if (intent?.action == ACTION_START_FROM_DESKTOP) { + startForegroundService( + Intent(this, VpnShareGatewayService::class.java) + .setAction(VpnShareGatewayService.ACTION_START) + ) + } + } + + companion object { + const val ACTION_START_FROM_DESKTOP = "org.vpnshare.action.START_FROM_DESKTOP" + } } diff --git a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt index ef9c5cc..ea953f2 100644 --- a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt +++ b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/VpnShareGatewayService.kt @@ -17,10 +17,12 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import org.vpnshare.domain.model.GatewayConfig import org.vpnshare.engine.RustVpnShareEngine +import org.vpnshare.gateway.socks.UsbSocksGateway class VpnShareGatewayService : Service() { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val engine = RustVpnShareEngine() + private val socksGateway = UsbSocksGateway() private lateinit var vpnDetector: VpnDetector override fun onCreate() { @@ -45,11 +47,13 @@ class VpnShareGatewayService : Service() { override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { + socksGateway.stop() scope.cancel() super.onDestroy() } private fun startGateway() { + socksGateway.start() val notification = buildNotification() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { startForeground( diff --git a/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/socks/UsbSocksGateway.kt b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/socks/UsbSocksGateway.kt new file mode 100644 index 0000000..30cbf9b --- /dev/null +++ b/apps/android/service/gateway/src/main/kotlin/org/vpnshare/gateway/socks/UsbSocksGateway.kt @@ -0,0 +1,328 @@ +package org.vpnshare.gateway.socks + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import java.io.EOFException +import java.io.InputStream +import java.io.OutputStream +import java.net.DatagramPacket +import java.net.DatagramSocket +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.util.concurrent.atomic.AtomicBoolean + +class UsbSocksGateway( + private val port: Int = DEFAULT_PORT +) { + private val running = AtomicBoolean(false) + private var serverSocket: ServerSocket? = null + private var scope: CoroutineScope? = null + private var acceptJob: Job? = null + + fun start() { + if (!running.compareAndSet(false, true)) return + val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + scope = serviceScope + val server = ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getLoopbackAddress(), port)) + } + serverSocket = server + acceptJob = serviceScope.launch { + while (isActive && running.get()) { + val client = runCatching { server.accept() }.getOrNull() ?: break + launch { handleClient(client) } + } + } + } + + fun stop() { + running.set(false) + runCatching { serverSocket?.close() } + acceptJob?.cancel() + scope?.cancel() + serverSocket = null + acceptJob = null + scope = null + } + + private fun handleClient(client: Socket) { + client.use { clientSocket -> + clientSocket.tcpNoDelay = true + clientSocket.soTimeout = HANDSHAKE_TIMEOUT_MS + val input = clientSocket.getInputStream() + val output = clientSocket.getOutputStream() + + runCatching { + negotiate(input, output) + when (val request = readRequest(input)) { + is SocksRequest.TcpConnect -> { + Socket().use { upstream -> + upstream.tcpNoDelay = true + upstream.connect(request.destination, CONNECT_TIMEOUT_MS) + writeSuccess(output, request.destination) + clientSocket.soTimeout = 0 + pipeBothWays(clientSocket, upstream) + } + } + SocksRequest.UdpForward -> { + writeSuccess(output, null) + clientSocket.soTimeout = 0 + forwardUdpInTcp(input, output) + } + } + }.onFailure { + runCatching { writeFailure(output) } + } + } + } + + private fun negotiate(input: InputStream, output: OutputStream) { + val version = input.readRequired() + require(version == SOCKS_VERSION) { "unsupported socks version" } + val methodCount = input.readRequired() + val methods = ByteArray(methodCount) + input.readFully(methods) + require(methods.any { it.toInt() == AUTH_NONE }) { "no supported auth method" } + output.write(byteArrayOf(SOCKS_VERSION.toByte(), AUTH_NONE.toByte())) + output.flush() + } + + private fun readRequest(input: InputStream): SocksRequest { + val version = input.readRequired() + val command = input.readRequired() + input.readRequired() + val addressType = input.readRequired() + require(version == SOCKS_VERSION) { "unsupported request version" } + + val destination = readAddress(input, addressType) + return when (command) { + COMMAND_CONNECT -> SocksRequest.TcpConnect(destination) + COMMAND_FORWARD_UDP -> SocksRequest.UdpForward + else -> error("unsupported command") + } + } + + private fun readAddress(input: InputStream, addressType: Int): InetSocketAddress { + val host = when (addressType) { + ADDRESS_IPV4 -> input.readBytesExact(IPV4_BYTES).joinToString(".") { (it.toInt() and 0xff).toString() } + ADDRESS_DOMAIN -> { + val length = input.readRequired() + input.readBytesExact(length).toString(Charsets.UTF_8) + } + ADDRESS_IPV6 -> InetAddress.getByAddress(input.readBytesExact(IPV6_BYTES)).hostAddress + else -> error("unsupported address type") + } + val port = (input.readRequired() shl 8) or input.readRequired() + return InetSocketAddress(host, port) + } + + private fun writeSuccess(output: OutputStream, destination: InetSocketAddress?) { + val port = destination?.port ?: 0 + output.write( + byteArrayOf( + SOCKS_VERSION.toByte(), + REPLY_SUCCESS.toByte(), + 0, + ADDRESS_IPV4.toByte(), + 127, + 0, + 0, + 1, + ((port ushr 8) and 0xff).toByte(), + (port and 0xff).toByte() + ) + ) + output.flush() + } + + private fun forwardUdpInTcp(input: InputStream, output: OutputStream) { + DatagramSocket().use { udpSocket -> + val alive = AtomicBoolean(true) + val receiver = Thread { + receiveUdpResponses(udpSocket, output, alive) + } + receiver.name = "vpnshare-socks-udp-receiver" + receiver.start() + + try { + while (alive.get()) { + val frame = readUdpTcpFrame(input) + val packet = DatagramPacket(frame.payload, frame.payload.size, frame.destination) + udpSocket.send(packet) + } + } catch (_: EOFException) { + } catch (_: SocketTimeoutException) { + } catch (_: Exception) { + } finally { + alive.set(false) + udpSocket.close() + receiver.join(UDP_RECEIVER_JOIN_MS) + } + } + } + + private fun readUdpTcpFrame(input: InputStream): UdpFrame { + val dataLength = (input.readRequired() shl 8) or input.readRequired() + val headerLength = input.readRequired() + require(dataLength <= UDP_MAX_PAYLOAD_BYTES) { "udp frame too large" } + require(headerLength >= UDP_MIN_HEADER_BYTES) { "udp header too small" } + + val addressType = input.readRequired() + val destination = readAddress(input, addressType) + val payload = input.readBytesExact(dataLength) + return UdpFrame(destination, payload) + } + + private fun receiveUdpResponses( + udpSocket: DatagramSocket, + output: OutputStream, + alive: AtomicBoolean + ) { + udpSocket.soTimeout = UDP_RECEIVE_TIMEOUT_MS + val buffer = ByteArray(UDP_RECEIVE_BUFFER_BYTES) + while (alive.get()) { + try { + val packet = DatagramPacket(buffer, buffer.size) + udpSocket.receive(packet) + val response = encodeUdpTcpFrame(packet.address, packet.port, packet.data, packet.offset, packet.length) + synchronized(output) { + output.write(response) + output.flush() + } + } catch (_: SocketTimeoutException) { + } catch (_: Exception) { + alive.set(false) + } + } + } + + private fun encodeUdpTcpFrame( + address: InetAddress, + port: Int, + payload: ByteArray, + offset: Int, + length: Int + ): ByteArray { + require(length <= UDP_MAX_PAYLOAD_BYTES) { "udp response too large" } + val addressBytes = address.address + val addressType = when (addressBytes.size) { + IPV4_BYTES -> ADDRESS_IPV4 + IPV6_BYTES -> ADDRESS_IPV6 + else -> error("unsupported udp response address") + } + val socksAddressLength = 1 + addressBytes.size + 2 + val headerLength = UDP_PREFIX_BYTES + socksAddressLength + val frame = ByteArray(headerLength + length) + frame[0] = ((length ushr 8) and 0xff).toByte() + frame[1] = (length and 0xff).toByte() + frame[2] = headerLength.toByte() + frame[3] = addressType.toByte() + addressBytes.copyInto(frame, destinationOffset = 4) + val portOffset = 4 + addressBytes.size + frame[portOffset] = ((port ushr 8) and 0xff).toByte() + frame[portOffset + 1] = (port and 0xff).toByte() + payload.copyInto( + frame, + destinationOffset = headerLength, + startIndex = offset, + endIndex = offset + length + ) + return frame + } + + private fun writeFailure(output: OutputStream) { + output.write(byteArrayOf(SOCKS_VERSION.toByte(), REPLY_FAILURE.toByte(), 0, ADDRESS_IPV4.toByte(), 0, 0, 0, 0, 0, 0)) + output.flush() + } + + private fun pipeBothWays(left: Socket, right: Socket) { + val leftToRight = Thread { copy(left.getInputStream(), right.getOutputStream(), right) } + val rightToLeft = Thread { copy(right.getInputStream(), left.getOutputStream(), left) } + leftToRight.name = "vpnshare-socks-client-to-phone" + rightToLeft.name = "vpnshare-socks-phone-to-client" + leftToRight.start() + rightToLeft.start() + leftToRight.join() + rightToLeft.join() + } + + private fun copy(input: InputStream, output: OutputStream, socketToClose: Socket) { + val buffer = ByteArray(COPY_BUFFER_BYTES) + try { + while (true) { + val read = input.read(buffer) + if (read < 0) break + output.write(buffer, 0, read) + output.flush() + } + } catch (_: SocketTimeoutException) { + } catch (_: Exception) { + } finally { + runCatching { socketToClose.shutdownOutput() } + } + } + + private fun InputStream.readRequired(): Int { + val value = read() + if (value < 0) throw EOFException() + return value + } + + private fun InputStream.readBytesExact(size: Int): ByteArray { + val bytes = ByteArray(size) + readFully(bytes) + return bytes + } + + private fun InputStream.readFully(bytes: ByteArray) { + var offset = 0 + while (offset < bytes.size) { + val read = read(bytes, offset, bytes.size - offset) + if (read < 0) throw EOFException() + offset += read + } + } + + private sealed interface SocksRequest { + data class TcpConnect(val destination: InetSocketAddress) : SocksRequest + data object UdpForward : SocksRequest + } + + private data class UdpFrame( + val destination: InetSocketAddress, + val payload: ByteArray + ) + + companion object { + const val DEFAULT_PORT = 10808 + private const val SOCKS_VERSION = 5 + private const val AUTH_NONE = 0 + private const val COMMAND_CONNECT = 1 + private const val COMMAND_FORWARD_UDP = 5 + private const val ADDRESS_IPV4 = 1 + private const val ADDRESS_DOMAIN = 3 + private const val ADDRESS_IPV6 = 4 + private const val REPLY_SUCCESS = 0 + private const val REPLY_FAILURE = 1 + private const val IPV4_BYTES = 4 + private const val IPV6_BYTES = 16 + private const val CONNECT_TIMEOUT_MS = 15_000 + private const val HANDSHAKE_TIMEOUT_MS = 10_000 + private const val COPY_BUFFER_BYTES = 32 * 1024 + private const val UDP_PREFIX_BYTES = 3 + private const val UDP_MIN_HEADER_BYTES = UDP_PREFIX_BYTES + 7 + private const val UDP_MAX_PAYLOAD_BYTES = 65_507 + private const val UDP_RECEIVE_BUFFER_BYTES = 65_535 + private const val UDP_RECEIVE_TIMEOUT_MS = 1_000 + private const val UDP_RECEIVER_JOIN_MS = 1_000L + } +} diff --git a/clients/desktop/Cargo.toml b/clients/desktop/Cargo.toml index 8f1dd47..a6062ac 100644 --- a/clients/desktop/Cargo.toml +++ b/clients/desktop/Cargo.toml @@ -11,6 +11,7 @@ name = "vpnshare-desktop" path = "src/main.rs" [dependencies] +tun2socks = "0.1.10" vpnshare-core = { path = "../../crates/vpnshare-core" } vpnshare-proto = { path = "../../crates/vpnshare-proto" } vpnshare-transport = { path = "../../crates/vpnshare-transport" } diff --git a/clients/desktop/src/main.rs b/clients/desktop/src/main.rs index 27bf9ca..f8c964f 100644 --- a/clients/desktop/src/main.rs +++ b/clients/desktop/src/main.rs @@ -1,14 +1,627 @@ -use vpnshare_core::{GatewayConfig, MtuPolicy}; -use vpnshare_proto::{encode_frame, FrameType, PROTOCOL_VERSION}; +use std::env; +use std::net::{SocketAddr, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::thread; +use std::time::{Duration, Instant}; -fn main() { - let config = GatewayConfig::default(); - let mtu = MtuPolicy::default(); - let hello = encode_frame(FrameType::Hello, 0, 1, b"vpnshare-desktop").expect("static hello frame"); - - println!("VPN Share desktop client skeleton"); - println!("protocol=VSHP/{PROTOCOL_VERSION}"); - println!("default_gateway_mtu={}", config.default_mtu); - println!("effective_tunnel_mtu={}", mtu.effective_tunnel_mtu()); - println!("hello_frame_bytes={}", hello.len()); +fn main() -> ExitCode { + let cli = Cli::parse(env::args().skip(1)); + match cli.command { + CommandKind::Help => { + print_help(); + ExitCode::SUCCESS + } + CommandKind::Status => match DesktopStatus::collect() { + Ok(status) => { + print_status(&status); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("status failed: {error}"); + ExitCode::from(1) + } + }, + CommandKind::AndroidLaunch => match DesktopStatus::find_adb().and_then(|adb| adb.launch_android_app()) { + Ok(output) => { + println!("{output}"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("android launch failed: {error}"); + ExitCode::from(1) + } + }, + CommandKind::Connect => match DesktopStatus::find_adb().and_then(|adb| adb.connect_usb_socks()) { + Ok(connection) => { + print_connection(&connection); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("connect failed: {error}"); + ExitCode::from(1) + } + }, + CommandKind::SystemGateway => match SystemGateway::run() { + Ok(()) => ExitCode::SUCCESS, + Err(error) => { + eprintln!("system gateway failed: {error}"); + ExitCode::from(1) + } + }, + CommandKind::SystemGatewayCleanup => match SystemGateway::cleanup() { + Ok(()) => { + println!("VPN Share system gateway routes removed"); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("cleanup failed: {error}"); + ExitCode::from(1) + } + }, + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Cli { + command: CommandKind, +} + +impl Cli { + fn parse(args: impl IntoIterator) -> Self { + let mut args = args.into_iter(); + let command = match args.next().as_deref() { + None | Some("-h") | Some("--help") | Some("help") => CommandKind::Help, + Some("status") | Some("doctor") => CommandKind::Status, + Some("android-launch") | Some("launch") => CommandKind::AndroidLaunch, + Some("connect") => CommandKind::Connect, + Some("system-gateway") | Some("gateway") | Some("tun") => CommandKind::SystemGateway, + Some("cleanup") | Some("disconnect") | Some("system-gateway-cleanup") => CommandKind::SystemGatewayCleanup, + Some(_) => CommandKind::Help, + }; + Self { command } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CommandKind { + Help, + Status, + AndroidLaunch, + Connect, + SystemGateway, + SystemGatewayCleanup, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct DesktopStatus { + os: String, + tun_available: bool, + adb: Option, +} + +impl DesktopStatus { + fn collect() -> Result { + let adb = match Self::find_adb() { + Ok(adb) => Some(adb.status()?), + Err(_) => None, + }; + Ok(Self { + os: env::consts::OS.to_string(), + tun_available: Path::new("/dev/net/tun").exists(), + adb, + }) + } + + fn find_adb() -> Result { + let mut candidates = Vec::new(); + if let Ok(path) = env::var("VPN_SHARE_ADB") { + candidates.push(PathBuf::from(path)); + } + candidates.push(PathBuf::from("/tmp/vpnshare-platform-tools-36/platform-tools/adb")); + candidates.push(PathBuf::from("/tmp/vpnshare-android-sdk/platform-tools/adb")); + candidates.push(PathBuf::from("adb")); + + for candidate in candidates { + let output = Command::new(&candidate).arg("version").output(); + if matches!(output, Ok(output) if output.status.success()) { + return Ok(Adb { path: candidate }); + } + } + + Err("adb not found; set VPN_SHARE_ADB or install Android platform-tools".to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct Adb { + path: PathBuf, +} + +impl Adb { + fn connect_usb_socks(&self) -> Result { + let devices_output = self.run(["devices", "-l"])?; + let devices = parse_adb_devices(&devices_output); + let device = devices + .iter() + .find(|device| device.state == "device") + .ok_or_else(|| "no authorized adb device found".to_string())?; + + self.launch_android_gateway()?; + self.run(["forward", "--remove", &format!("tcp:{SOCKS_PORT}")]).ok(); + self.run(["forward", &format!("tcp:{SOCKS_PORT}"), &format!("tcp:{SOCKS_PORT}")])?; + + let endpoint: SocketAddr = format!("127.0.0.1:{SOCKS_PORT}") + .parse() + .map_err(|error| format!("invalid local socks endpoint: {error}"))?; + TcpStream::connect_timeout(&endpoint, Duration::from_secs(3)) + .map_err(|error| format!("adb forward is present, but local socks port did not open: {error}"))?; + + Ok(UsbSocksConnection { + device_serial: device.serial.clone(), + socks_url: format!("socks5h://127.0.0.1:{SOCKS_PORT}"), + }) + } + + fn status(&self) -> Result { + let devices_output = self.run(["devices", "-l"])?; + let devices = parse_adb_devices(&devices_output); + let package_installed = self + .run(["shell", "pm", "path", "org.vpnshare"]) + .map(|output| output.contains("package:")) + .unwrap_or(false); + let app_pid = self + .run(["shell", "pidof", "org.vpnshare"]) + .ok() + .and_then(|output| output.split_whitespace().next().map(str::to_string)); + let gateway_foreground = self + .run(["shell", "dumpsys", "activity", "services", "org.vpnshare"]) + .map(|output| output.contains("isForeground=true")) + .unwrap_or(false); + + Ok(AdbStatus { + path: self.path.display().to_string(), + devices, + package_installed, + app_pid, + gateway_foreground, + }) + } + + fn launch_android_app(&self) -> Result { + self.run([ + "shell", + "am", + "start", + "-W", + "-n", + "org.vpnshare/org.vpnshare.app.MainActivity", + ]) + } + + fn launch_android_gateway(&self) -> Result { + self.run([ + "shell", + "am", + "start", + "-W", + "-a", + "org.vpnshare.action.START_FROM_DESKTOP", + "-n", + "org.vpnshare/org.vpnshare.app.MainActivity", + ]) + } + + fn run(&self, args: [&str; N]) -> Result { + let output = Command::new(&self.path) + .args(args) + .output() + .map_err(|error| format!("failed to run {}: {error}", self.path.display()))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AdbStatus { + path: String, + devices: Vec, + package_installed: bool, + app_pid: Option, + gateway_foreground: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct AdbDevice { + serial: String, + state: String, + detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct UsbSocksConnection { + device_serial: String, + socks_url: String, +} + +struct SystemGateway; + +impl SystemGateway { + fn run() -> Result<(), String> { + ensure_linux()?; + ensure_tun_available()?; + ensure_socks_endpoint_open()?; + ensure_root()?; + + println!("Starting VPN Share system gateway"); + println!("interface={TUN_NAME}"); + println!("socks=127.0.0.1:{SOCKS_PORT}"); + + let config = tun2socks_config(); + let tunnel = thread::spawn(move || tun2socks::main_from_str(&config, -1)); + + if let Err(error) = wait_for_interface(TUN_NAME, Duration::from_secs(8)) { + tun2socks::quit(); + let _ = tunnel.join(); + return Err(error); + } + + let dns_configured = configure_dns(); + if let Err(error) = install_split_default_routes() { + cleanup_split_default_routes(); + if dns_configured { + cleanup_dns(); + } + tun2socks::quit(); + let _ = tunnel.join(); + return Err(error); + } + + println!(); + println!("VPN Share system gateway is active"); + println!("All IPv4 traffic is routed through {TUN_NAME}; IPv6 is enabled when the host supports it."); + if dns_configured { + println!("dns=systemd-resolved via 1.1.1.1/8.8.8.8"); + } else { + println!("dns=unchanged; install/use systemd-resolved if your current DNS is LAN-only"); + } + println!("Keep this process open. Press Ctrl+C to stop."); + println!(); + + let result = match tunnel.join() { + Ok(Ok(())) => Ok(()), + Ok(Err(code)) => Err(format!("tun2socks exited with code {code}")), + Err(_) => Err("tun2socks thread panicked".to_string()), + }; + + cleanup_split_default_routes(); + if dns_configured { + cleanup_dns(); + } + result + } + + fn cleanup() -> Result<(), String> { + ensure_linux()?; + ensure_root()?; + cleanup_split_default_routes(); + cleanup_dns(); + Ok(()) + } +} + +fn parse_adb_devices(output: &str) -> Vec { + output + .lines() + .skip(1) + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() { + return None; + } + let mut fields = line.split_whitespace(); + let serial = fields.next()?.to_string(); + let state = fields.next()?.to_string(); + let detail = fields.collect::>().join(" "); + Some(AdbDevice { serial, state, detail }) + }) + .collect() +} + +fn ensure_linux() -> Result<(), String> { + if env::consts::OS == "linux" { + Ok(()) + } else { + Err("system gateway mode is implemented for Linux in this build".to_string()) + } +} + +fn ensure_tun_available() -> Result<(), String> { + if Path::new("/dev/net/tun").exists() { + Ok(()) + } else { + Err("/dev/net/tun is missing; enable the Linux tun module first".to_string()) + } +} + +fn ensure_socks_endpoint_open() -> Result<(), String> { + let endpoint = socks_endpoint()?; + TcpStream::connect_timeout(&endpoint, Duration::from_secs(2)) + .map(|_| ()) + .map_err(|error| format!("127.0.0.1:{SOCKS_PORT} is not open ({error}). Run `vpnshare-desktop connect` first.")) +} + +fn ensure_root() -> Result<(), String> { + let output = Command::new("id") + .arg("-u") + .output() + .map_err(|error| format!("failed to check current user id: {error}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).trim().to_string()); + } + + let uid = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if uid == "0" { + Ok(()) + } else { + Err( + "system gateway mode needs root/CAP_NET_ADMIN on the desktop. Run `sudo ./target/debug/vpnshare-desktop system-gateway` after `./target/debug/vpnshare-desktop connect`." + .to_string(), + ) + } +} + +fn socks_endpoint() -> Result { + format!("127.0.0.1:{SOCKS_PORT}") + .parse() + .map_err(|error| format!("invalid local socks endpoint: {error}")) +} + +fn tun2socks_config() -> String { + format!( + r#"tunnel: + name: {TUN_NAME} + mtu: {TUN_MTU} + multi-queue: false + ipv4: {TUN_IPV4} + ipv6: '{TUN_IPV6}' + +socks5: + address: 127.0.0.1 + port: {SOCKS_PORT} + udp: 'tcp' + +misc: + task-stack-size: 20480 + tcp-buffer-size: 65536 + connect-timeout: 5000 + read-write-timeout: 600000 + log-file: stderr + log-level: warn +"# + ) +} + +fn wait_for_interface(name: &str, timeout: Duration) -> Result<(), String> { + let start = Instant::now(); + while start.elapsed() < timeout { + if command_success("ip", &["link", "show", name]) { + return Ok(()); + } + thread::sleep(Duration::from_millis(100)); + } + Err(format!( + "{name} did not appear; tun2socks could not create the TUN device" + )) +} + +fn install_split_default_routes() -> Result<(), String> { + run_command( + "ip", + &["route", "replace", "0.0.0.0/1", "dev", TUN_NAME, "metric", "10"], + )?; + run_command( + "ip", + &["route", "replace", "128.0.0.0/1", "dev", TUN_NAME, "metric", "10"], + )?; + + if let Err(error) = run_command( + "ip", + &["-6", "route", "replace", "::/1", "dev", TUN_NAME, "metric", "10"], + ) { + eprintln!("warning: IPv6 ::/1 route was not installed: {error}"); + } + if let Err(error) = run_command( + "ip", + &["-6", "route", "replace", "8000::/1", "dev", TUN_NAME, "metric", "10"], + ) { + eprintln!("warning: IPv6 8000::/1 route was not installed: {error}"); + } + + Ok(()) +} + +fn cleanup_split_default_routes() { + let routes: &[&[&str]] = &[ + &["route", "del", "0.0.0.0/1", "dev", TUN_NAME], + &["route", "del", "128.0.0.0/1", "dev", TUN_NAME], + &["-6", "route", "del", "::/1", "dev", TUN_NAME], + &["-6", "route", "del", "8000::/1", "dev", TUN_NAME], + ]; + for route in routes { + let _ = run_command("ip", route); + } +} + +fn configure_dns() -> bool { + if !command_success("resolvectl", &["--version"]) { + return false; + } + + let dns = run_command( + "resolvectl", + &[ + "dns", + TUN_NAME, + "1.1.1.1", + "8.8.8.8", + "2606:4700:4700::1111", + "2001:4860:4860::8888", + ], + ); + let domain = run_command("resolvectl", &["domain", TUN_NAME, "~."]); + + match (dns, domain) { + (Ok(_), Ok(_)) => true, + (Err(error), _) | (_, Err(error)) => { + eprintln!("warning: could not configure systemd-resolved DNS for {TUN_NAME}: {error}"); + false + } + } +} + +fn cleanup_dns() { + if command_success("resolvectl", &["--version"]) { + let _ = run_command("resolvectl", &["revert", TUN_NAME]); + } +} + +fn command_success(program: &str, args: &[&str]) -> bool { + Command::new(program) + .args(args) + .output() + .map(|output| output.status.success()) + .unwrap_or(false) +} + +fn run_command(program: &str, args: &[&str]) -> Result { + let output = Command::new(program) + .args(args) + .output() + .map_err(|error| format!("failed to run {program}: {error}"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let message = if stderr.is_empty() { stdout } else { stderr }; + return Err(if message.is_empty() { + format!("{program} exited with {}", output.status) + } else { + message + }); + } + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn print_help() { + println!("VPN Share desktop CLI"); + println!(); + println!("Commands:"); + println!(" status Check desktop prerequisites and connected Android app state"); + println!(" android-launch Bring the Android VPN Share app to the foreground"); + println!(" connect Start USB SOCKS5 sharing through the Android gateway"); + println!(" system-gateway Create vpnshare0 and route OS traffic through the phone"); + println!(" cleanup Remove VPN Share split-default routes"); + println!(); + println!("Environment:"); + println!(" VPN_SHARE_ADB Optional path to adb"); +} + +fn print_connection(connection: &UsbSocksConnection) { + println!("VPN Share USB SOCKS5 sharing is ready"); + println!("device={}", connection.device_serial); + println!("proxy={}", connection.socks_url); + println!(); + println!("For a real OS gateway, keep this ADB forward active and run:"); + println!(" sudo ./target/debug/vpnshare-desktop system-gateway"); + println!(); + println!("Proxy endpoint is also available for apps that explicitly support SOCKS5:"); + println!(" ALL_PROXY={} curl https://ifconfig.me", connection.socks_url); +} + +fn print_status(status: &DesktopStatus) { + println!("VPN Share desktop status"); + println!("os={}", status.os); + println!("tun_available={}", status.tun_available); + println!( + "desktop_packet_tunnel={}", + if status.tun_available { + "available" + } else { + "unavailable" + } + ); + + match &status.adb { + Some(adb) => { + println!("adb={}", adb.path); + println!("adb_devices={}", adb.devices.len()); + for device in &adb.devices { + println!( + "device serial={} state={} {}", + device.serial, device.state, device.detail + ); + } + println!("android_package_installed={}", adb.package_installed); + println!("android_app_pid={}", adb.app_pid.as_deref().unwrap_or("not_running")); + println!("android_gateway_foreground={}", adb.gateway_foreground); + } + None => { + println!("adb=not_found"); + } + } +} + +const SOCKS_PORT: u16 = 10808; +const TUN_NAME: &str = "vpnshare0"; +const TUN_IPV4: &str = "198.18.0.1"; +const TUN_IPV6: &str = "fc00::1"; +const TUN_MTU: u16 = 1280; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_default_help() { + assert_eq!(Cli::parse([]).command, CommandKind::Help); + } + + #[test] + fn parses_status_aliases() { + assert_eq!(Cli::parse(["doctor".to_string()]).command, CommandKind::Status); + } + + #[test] + fn parses_system_gateway_aliases() { + assert_eq!(Cli::parse(["gateway".to_string()]).command, CommandKind::SystemGateway); + assert_eq!( + Cli::parse(["disconnect".to_string()]).command, + CommandKind::SystemGatewayCleanup + ); + } + + #[test] + fn renders_tun2socks_config() { + let config = tun2socks_config(); + + assert!(config.contains("name: vpnshare0")); + assert!(config.contains("port: 10808")); + assert!(config.contains("udp: 'tcp'")); + } + + #[test] + fn parses_adb_devices() { + let devices = parse_adb_devices("List of devices attached\nR5CX33ESWGH device usb:1-6 model:SM_A556E\n\n"); + + assert_eq!( + devices, + vec![AdbDevice { + serial: "R5CX33ESWGH".to_string(), + state: "device".to_string(), + detail: "usb:1-6 model:SM_A556E".to_string() + }] + ); + } } diff --git a/docs/desktop-client.md b/docs/desktop-client.md new file mode 100644 index 0000000..5d42df5 --- /dev/null +++ b/docs/desktop-client.md @@ -0,0 +1,78 @@ +# Desktop Client + +It can: + +- Check whether Linux TUN support exists. +- Find `adb`. +- Check whether the Android VPN Share app is installed. +- Check whether the Android gateway foreground service is running. +- Bring the Android app to the foreground. +- Start the Android gateway and create `adb forward tcp:10808 tcp:10808`. +- Provide a local `socks5h://127.0.0.1:10808` proxy endpoint. +- On Linux, create a `vpnshare0` TUN device and route OS traffic through the + phone using `tun2socks`. +- Forward DNS/UDP through the Android gateway using SOCKS5 UDP-over-TCP. + +It cannot yet pair with the phone over VSHP or provide native Windows/macOS TUN +drivers. + +## Build + +```bash +cargo build -p vpnshare-desktop +``` + +## Run + +```bash +./target/debug/vpnshare-desktop status +./target/debug/vpnshare-desktop connect +sudo ./target/debug/vpnshare-desktop system-gateway +./target/debug/vpnshare-desktop android-launch +``` + +If `adb` is not on `PATH`, point the CLI to it: + +```bash +VPN_SHARE_ADB=/path/to/adb ./target/debug/vpnshare-desktop status +``` + +After `connect`, use the local SOCKS5 proxy with apps that support SOCKS: + +```bash +ALL_PROXY=socks5h://127.0.0.1:10808 curl https://ifconfig.me +chromium --proxy-server=socks5h://127.0.0.1:10808 +``` + +The `socks5h` form is important because DNS names are resolved through the +phone-side gateway instead of the desktop. + +## Linux System Gateway + +For normal OS/app routing on Linux: + +1. Connect the phone with USB debugging enabled. +2. Start the Android VPN app you want to share. +3. Run `./target/debug/vpnshare-desktop connect`. +4. Run `sudo ./target/debug/vpnshare-desktop system-gateway`. +5. Keep the `system-gateway` process open while sharing. + +`system-gateway` creates `vpnshare0`, starts `tun2socks`, installs split-default +routes (`0.0.0.0/1` and `128.0.0.0/1`), and configures systemd-resolved DNS on +`vpnshare0` when `resolvectl` is available. + +Cleanup command: + +```bash +sudo ./target/debug/vpnshare-desktop cleanup +``` + +## Next Implementation Step + +The next milestone is replacing the interim SOCKS transport with the full VSHP +packet tunnel: + +1. Connect to the Android gateway over USB/WiFi/hotspot transports. +2. Pair using VSHP. +3. Encrypt IP packet frames end to end. +4. Add Windows/macOS packet adapters.