implement real gateway path.
This commit is contained in:
12
README.md
12
README.md
@@ -10,8 +10,8 @@ through the phone automatically.
|
|||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
This repository contains the production architecture, protocol specification,
|
This repository contains the production architecture, protocol specification,
|
||||||
Android project scaffold, Rust core scaffold, and the first USB-first engine
|
Android project scaffold, Rust core scaffold, and a Linux-first USB gateway
|
||||||
interfaces. It is not yet a complete packet-forwarding release.
|
prototype.
|
||||||
|
|
||||||
The first shippable milestone is:
|
The first shippable milestone is:
|
||||||
|
|
||||||
@@ -21,6 +21,11 @@ The first shippable milestone is:
|
|||||||
- Desktop client foundation for Windows, Linux, and macOS.
|
- Desktop client foundation for Windows, Linux, and macOS.
|
||||||
- Encrypted VSHP tunnel with QR/code pairing.
|
- 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
|
## Important Platform Constraint
|
||||||
|
|
||||||
VPN Share uses companion clients on receiving devices. This is required because
|
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
|
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
|
## License
|
||||||
|
|
||||||
Apache-2.0. See [LICENSE](LICENSE).
|
Apache-2.0. See [LICENSE](LICENSE).
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.vpnshare.gateway.VpnShareGatewayService
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
maybeStartShare(intent)
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
ShareScreen(
|
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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,10 +17,12 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.vpnshare.domain.model.GatewayConfig
|
import org.vpnshare.domain.model.GatewayConfig
|
||||||
import org.vpnshare.engine.RustVpnShareEngine
|
import org.vpnshare.engine.RustVpnShareEngine
|
||||||
|
import org.vpnshare.gateway.socks.UsbSocksGateway
|
||||||
|
|
||||||
class VpnShareGatewayService : Service() {
|
class VpnShareGatewayService : Service() {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val engine = RustVpnShareEngine()
|
private val engine = RustVpnShareEngine()
|
||||||
|
private val socksGateway = UsbSocksGateway()
|
||||||
private lateinit var vpnDetector: VpnDetector
|
private lateinit var vpnDetector: VpnDetector
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@@ -45,11 +47,13 @@ class VpnShareGatewayService : Service() {
|
|||||||
override fun onBind(intent: Intent?): IBinder? = null
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
socksGateway.stop()
|
||||||
scope.cancel()
|
scope.cancel()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startGateway() {
|
private fun startGateway() {
|
||||||
|
socksGateway.start()
|
||||||
val notification = buildNotification()
|
val notification = buildNotification()
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
startForeground(
|
startForeground(
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ name = "vpnshare-desktop"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
tun2socks = "0.1.10"
|
||||||
vpnshare-core = { path = "../../crates/vpnshare-core" }
|
vpnshare-core = { path = "../../crates/vpnshare-core" }
|
||||||
vpnshare-proto = { path = "../../crates/vpnshare-proto" }
|
vpnshare-proto = { path = "../../crates/vpnshare-proto" }
|
||||||
vpnshare-transport = { path = "../../crates/vpnshare-transport" }
|
vpnshare-transport = { path = "../../crates/vpnshare-transport" }
|
||||||
|
|||||||
@@ -1,14 +1,627 @@
|
|||||||
use vpnshare_core::{GatewayConfig, MtuPolicy};
|
use std::env;
|
||||||
use vpnshare_proto::{encode_frame, FrameType, PROTOCOL_VERSION};
|
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() {
|
fn main() -> ExitCode {
|
||||||
let config = GatewayConfig::default();
|
let cli = Cli::parse(env::args().skip(1));
|
||||||
let mtu = MtuPolicy::default();
|
match cli.command {
|
||||||
let hello = encode_frame(FrameType::Hello, 0, 1, b"vpnshare-desktop").expect("static hello frame");
|
CommandKind::Help => {
|
||||||
|
print_help();
|
||||||
println!("VPN Share desktop client skeleton");
|
ExitCode::SUCCESS
|
||||||
println!("protocol=VSHP/{PROTOCOL_VERSION}");
|
}
|
||||||
println!("default_gateway_mtu={}", config.default_mtu);
|
CommandKind::Status => match DesktopStatus::collect() {
|
||||||
println!("effective_tunnel_mtu={}", mtu.effective_tunnel_mtu());
|
Ok(status) => {
|
||||||
println!("hello_frame_bytes={}", hello.len());
|
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
78
docs/desktop-client.md
Normal 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.
|
||||||
Reference in New Issue
Block a user