implement real gateway path.
Some checks failed
CI / Rust (push) Successful in 25s
CI / Android (push) Failing after 2s

This commit is contained in:
2026-05-31 20:10:11 +03:30
parent 442fad6b05
commit 266cae92ce
7 changed files with 1062 additions and 17 deletions

View File

@@ -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).

View File

@@ -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"
}
}

View File

@@ -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(

View File

@@ -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
}
}

View File

@@ -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" }

View File

@@ -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<Item = String>) -> 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<AdbStatus>,
}
impl DesktopStatus {
fn collect() -> Result<Self, String> {
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<Adb, String> {
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<UsbSocksConnection, String> {
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<AdbStatus, String> {
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<String, String> {
self.run([
"shell",
"am",
"start",
"-W",
"-n",
"org.vpnshare/org.vpnshare.app.MainActivity",
])
}
fn launch_android_gateway(&self) -> Result<String, String> {
self.run([
"shell",
"am",
"start",
"-W",
"-a",
"org.vpnshare.action.START_FROM_DESKTOP",
"-n",
"org.vpnshare/org.vpnshare.app.MainActivity",
])
}
fn run<const N: usize>(&self, args: [&str; N]) -> Result<String, String> {
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<AdbDevice>,
package_installed: bool,
app_pid: Option<String>,
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<AdbDevice> {
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::<Vec<_>>().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<SocketAddr, String> {
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<String, String> {
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()
}]
);
}
}

78
docs/desktop-client.md Normal file
View File

@@ -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.