Implement
This commit is contained in:
38
AGENTS.md
Normal file
38
AGENTS.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# 概要
|
||||||
|
アプリ名: DummyVPN
|
||||||
|
ターゲットAPIレベル: 33 (Android 13)
|
||||||
|
言語: Kotlin
|
||||||
|
UIフレームワーク: Jetpack Compose
|
||||||
|
|
||||||
|
DummyVPNは、実際にリモートのVPNサーバーと接続するのではなく、アプリで受けたパケットをただインターネットに中継するVPNアプリである。
|
||||||
|
そのためWi-Fiやモバイルネットワークへのパケット転送のみを行う。
|
||||||
|
|
||||||
|
# 画面一覧
|
||||||
|
## メイン画面
|
||||||
|
シンプルかつモダンなデザインにする。まぶしい色は使わない(白は例外)。
|
||||||
|
|
||||||
|
### VPNのオンオフボタン
|
||||||
|
ボタンのサイズはなるべく大きく分かりやすいようにする。
|
||||||
|
VPNが有効の場合は「ON」、無効の場合は「OFF」と表示する。
|
||||||
|
ONの場合はボタンの色を変えて、視覚的に分かりやすくする。
|
||||||
|
|
||||||
|
# 機能一覧
|
||||||
|
## VPN機能
|
||||||
|
メインの機能。概要の通り、すべてのパケットを通常のネットワークに転送する。何も制御しない。
|
||||||
|
VPNはすべてのアプリに適用する。
|
||||||
|
|
||||||
|
## 通知での操作機能
|
||||||
|
VPNが有効の場合は、OSに通知を表示して動作している旨を表示する。
|
||||||
|
また、「停止」ボタンを設け、通知から本アプリのVPN機能を停止できるようにする。
|
||||||
|
|
||||||
|
# 動作
|
||||||
|
バックグラウンドサービスを使用して、アプリを離れても動作できるようにする。
|
||||||
|
|
||||||
|
# コーディングの注意事項
|
||||||
|
- 権限が必要な機能は、requestPermissionを使用してアプリの起動時にユーザーに権限付与のリクエストをする。
|
||||||
|
- テストは作成しない。
|
||||||
|
|
||||||
|
# 技術的な事項
|
||||||
|
- パケットのパススルーはtun2socksを使用する。
|
||||||
|
そのためローカルにSocksプロキシを作成する。
|
||||||
|
VPN開始時にSocksプロキシも起動し、停止時にプロキシも停止する。
|
||||||
@@ -37,6 +37,11 @@ android {
|
|||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
}
|
}
|
||||||
|
packaging {
|
||||||
|
jniLibs {
|
||||||
|
useLegacyPackaging = true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
@@ -55,4 +60,4 @@ dependencies {
|
|||||||
androidTestImplementation(libs.androidx.junit)
|
androidTestImplementation(libs.androidx.junit)
|
||||||
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
debugImplementation(libs.androidx.compose.ui.test.manifest)
|
||||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,12 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:tools="http://schemas.android.com/tools">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:allowBackup="true"
|
android:allowBackup="true"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
@@ -22,6 +28,20 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".DummyVpnService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="specialUse"
|
||||||
|
android:permission="android.permission.BIND_VPN_SERVICE">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.net.VpnService" />
|
||||||
|
</intent-filter>
|
||||||
|
<property
|
||||||
|
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||||
|
android:value="Pass-through local VPN service" />
|
||||||
|
</service>
|
||||||
|
<meta-data android:name="android.telephony.PROPERTY_SATELLITE_DATA_OPTIMIZED" android:value="jp.cyberrex.dummyvpn" />
|
||||||
</application>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
186
app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt
Normal file
186
app/src/main/java/jp/cyberrex/dummyvpn/DummyVpnService.kt
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package jp.cyberrex.dummyvpn
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.core.content.getSystemService
|
||||||
|
import java.net.Inet4Address
|
||||||
|
|
||||||
|
class DummyVpnService : VpnService() {
|
||||||
|
private var tunInterface: ParcelFileDescriptor? = null
|
||||||
|
private var socksProxy: LocalSocksProxy? = null
|
||||||
|
private var tun2SocksProcess: Tun2SocksProcess? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
when (intent?.action) {
|
||||||
|
ACTION_STOP -> stopVpn()
|
||||||
|
ACTION_START, null -> startVpn()
|
||||||
|
}
|
||||||
|
return START_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopVpn()
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startVpn() {
|
||||||
|
if (VpnStatusStore.status.value == VpnStatus.On) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
VpnStatusStore.set(VpnStatus.Starting)
|
||||||
|
|
||||||
|
try {
|
||||||
|
createNotificationChannel()
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
buildNotification(),
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||||
|
android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val underlyingNetwork = findUnderlyingNetwork()
|
||||||
|
val builder = Builder()
|
||||||
|
.setSession(getString(R.string.app_name))
|
||||||
|
.setMtu(1500)
|
||||||
|
.addAddress("198.18.0.2", 24)
|
||||||
|
.addRoute("0.0.0.0", 0)
|
||||||
|
|
||||||
|
val dnsServers = findDnsServers(underlyingNetwork)
|
||||||
|
dnsServers.ifEmpty { listOf("1.1.1.1") }.forEach { dnsServer ->
|
||||||
|
builder.addDnsServer(dnsServer)
|
||||||
|
}
|
||||||
|
underlyingNetwork?.let { network ->
|
||||||
|
builder.setUnderlyingNetworks(arrayOf(network))
|
||||||
|
}
|
||||||
|
|
||||||
|
val tun = builder.establish()
|
||||||
|
?: throw IllegalStateException("VPN interface could not be established")
|
||||||
|
|
||||||
|
tunInterface = tun
|
||||||
|
|
||||||
|
val proxy = LocalSocksProxy(this)
|
||||||
|
proxy.start()
|
||||||
|
socksProxy = proxy
|
||||||
|
|
||||||
|
val process = Tun2SocksProcess(this)
|
||||||
|
process.start(tun, proxy.port)
|
||||||
|
tun2SocksProcess = process
|
||||||
|
VpnStatusStore.set(VpnStatus.On)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
stopRuntime()
|
||||||
|
val message = error.message ?: getString(R.string.vpn_error_unknown)
|
||||||
|
VpnStatusStore.set(VpnStatus.Error(message))
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopVpn() {
|
||||||
|
if (VpnStatusStore.status.value != VpnStatus.Off) {
|
||||||
|
VpnStatusStore.set(VpnStatus.Stopping)
|
||||||
|
}
|
||||||
|
stopRuntime()
|
||||||
|
VpnStatusStore.set(VpnStatus.Off)
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopRuntime() {
|
||||||
|
tun2SocksProcess?.stop()
|
||||||
|
tun2SocksProcess = null
|
||||||
|
|
||||||
|
socksProxy?.stop()
|
||||||
|
socksProxy = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
tunInterface?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
tunInterface = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createNotificationChannel() {
|
||||||
|
val manager = getSystemService<NotificationManager>() ?: return
|
||||||
|
val channel = NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
getString(R.string.vpn_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW
|
||||||
|
)
|
||||||
|
manager.createNotificationChannel(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildNotification(): Notification {
|
||||||
|
val launchIntent = PendingIntent.getActivity(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
Intent(this, MainActivity::class.java),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
val stopIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
1,
|
||||||
|
Intent(this, DummyVpnService::class.java).setAction(ACTION_STOP),
|
||||||
|
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
|
||||||
|
)
|
||||||
|
|
||||||
|
return NotificationCompat.Builder(this, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_launcher_foreground)
|
||||||
|
.setContentTitle(getString(R.string.vpn_notification_title))
|
||||||
|
.setContentText(getString(R.string.vpn_notification_text))
|
||||||
|
.setContentIntent(launchIntent)
|
||||||
|
.setOngoing(true)
|
||||||
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
|
.addAction(0, getString(R.string.vpn_notification_stop), stopIntent)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findUnderlyingNetwork(): Network? {
|
||||||
|
val connectivityManager = getSystemService<ConnectivityManager>() ?: return null
|
||||||
|
return connectivityManager.allNetworks.firstOrNull { network ->
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return@firstOrNull false
|
||||||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findDnsServers(network: Network?): List<String> {
|
||||||
|
val connectivityManager = getSystemService<ConnectivityManager>() ?: return emptyList()
|
||||||
|
val linkProperties = connectivityManager.getLinkProperties(network ?: return emptyList())
|
||||||
|
?: return emptyList()
|
||||||
|
return linkProperties.dnsServers
|
||||||
|
.filterIsInstance<Inet4Address>()
|
||||||
|
.mapNotNull { it.hostAddress }
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_START = "jp.cyberrex.dummyvpn.action.START"
|
||||||
|
const val ACTION_STOP = "jp.cyberrex.dummyvpn.action.STOP"
|
||||||
|
|
||||||
|
private const val CHANNEL_ID = "dummy_vpn"
|
||||||
|
private const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
|
fun start(context: android.content.Context) {
|
||||||
|
val intent = Intent(context, DummyVpnService::class.java).setAction(ACTION_START)
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: android.content.Context) {
|
||||||
|
context.startService(Intent(context, DummyVpnService::class.java).setAction(ACTION_STOP))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
470
app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt
Normal file
470
app/src/main/java/jp/cyberrex/dummyvpn/LocalSocksProxy.kt
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
package jp.cyberrex.dummyvpn
|
||||||
|
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.EOFException
|
||||||
|
import java.io.IOException
|
||||||
|
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.SocketException
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.util.Collections
|
||||||
|
import java.util.concurrent.ExecutorService
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class LocalSocksProxy(private val vpnService: VpnService) {
|
||||||
|
private var serverSocket: ServerSocket? = null
|
||||||
|
private var executor: ExecutorService? = null
|
||||||
|
private val openSockets = Collections.synchronizedSet(mutableSetOf<Socket>())
|
||||||
|
private val openDatagramSockets = Collections.synchronizedSet(mutableSetOf<DatagramSocket>())
|
||||||
|
|
||||||
|
val port: Int
|
||||||
|
get() = serverSocket?.localPort ?: 0
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
val server = ServerSocket()
|
||||||
|
server.reuseAddress = true
|
||||||
|
server.bind(InetSocketAddress(InetAddress.getByName(LOCALHOST), 0))
|
||||||
|
serverSocket = server
|
||||||
|
|
||||||
|
val pool = Executors.newCachedThreadPool()
|
||||||
|
executor = pool
|
||||||
|
pool.execute {
|
||||||
|
acceptLoop(server, pool)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
try {
|
||||||
|
serverSocket?.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
serverSocket = null
|
||||||
|
|
||||||
|
synchronized(openSockets) {
|
||||||
|
openSockets.forEach { socket ->
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
openSockets.clear()
|
||||||
|
}
|
||||||
|
synchronized(openDatagramSockets) {
|
||||||
|
openDatagramSockets.forEach { socket ->
|
||||||
|
socket.close()
|
||||||
|
}
|
||||||
|
openDatagramSockets.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
executor?.shutdownNow()
|
||||||
|
try {
|
||||||
|
executor?.awaitTermination(1, TimeUnit.SECONDS)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
}
|
||||||
|
executor = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun acceptLoop(server: ServerSocket, pool: ExecutorService) {
|
||||||
|
while (!server.isClosed) {
|
||||||
|
try {
|
||||||
|
val client = server.accept()
|
||||||
|
openSockets.add(client)
|
||||||
|
pool.execute {
|
||||||
|
handleClient(client)
|
||||||
|
}
|
||||||
|
} catch (_: SocketException) {
|
||||||
|
break
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleClient(client: Socket) {
|
||||||
|
client.use {
|
||||||
|
try {
|
||||||
|
val input = client.getInputStream()
|
||||||
|
val output = client.getOutputStream()
|
||||||
|
negotiate(input, output)
|
||||||
|
|
||||||
|
val requestVersion = input.readByte()
|
||||||
|
if (requestVersion != SOCKS_VERSION) {
|
||||||
|
throw IOException("Unsupported SOCKS request version")
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val command = input.readByte()) {
|
||||||
|
COMMAND_CONNECT -> handleConnect(client, input, output)
|
||||||
|
COMMAND_UDP_ASSOCIATE -> handleUdpAssociate(client, input, output)
|
||||||
|
else -> {
|
||||||
|
skipAddress(input)
|
||||||
|
sendReply(output, REPLY_COMMAND_NOT_SUPPORTED, InetAddress.getByName(LOCALHOST), 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: IOException) {
|
||||||
|
} finally {
|
||||||
|
openSockets.remove(client)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun negotiate(input: InputStream, output: OutputStream) {
|
||||||
|
val version = input.readByte()
|
||||||
|
if (version != SOCKS_VERSION) throw IOException("Unsupported SOCKS version")
|
||||||
|
|
||||||
|
val methodCount = input.readByte()
|
||||||
|
var supportsNoAuth = false
|
||||||
|
repeat(methodCount) {
|
||||||
|
if (input.readByte() == METHOD_NO_AUTH) supportsNoAuth = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supportsNoAuth) {
|
||||||
|
output.write(byteArrayOf(SOCKS_VERSION.toByte(), METHOD_NO_AUTH.toByte()))
|
||||||
|
} else {
|
||||||
|
output.write(byteArrayOf(SOCKS_VERSION.toByte(), METHOD_NOT_ACCEPTABLE.toByte()))
|
||||||
|
throw IOException("No acceptable SOCKS authentication method")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleConnect(client: Socket, input: InputStream, output: OutputStream) {
|
||||||
|
input.readByte()
|
||||||
|
val destination = readAddress(input)
|
||||||
|
val remote = createOutboundTcpSocket()
|
||||||
|
openSockets.add(remote)
|
||||||
|
|
||||||
|
try {
|
||||||
|
remote.connect(InetSocketAddress(destination.host, destination.port), CONNECT_TIMEOUT_MS)
|
||||||
|
sendReply(output, REPLY_SUCCEEDED, remote.localAddress, remote.localPort)
|
||||||
|
pipeBothWays(client, remote)
|
||||||
|
} catch (error: IOException) {
|
||||||
|
Log.w(TAG, "CONNECT ${destination.host}:${destination.port} failed: ${error.message}")
|
||||||
|
sendReply(output, REPLY_HOST_UNREACHABLE, InetAddress.getByName(LOCALHOST), 0)
|
||||||
|
} finally {
|
||||||
|
openSockets.remove(remote)
|
||||||
|
try {
|
||||||
|
remote.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUdpAssociate(client: Socket, input: InputStream, output: OutputStream) {
|
||||||
|
input.readByte()
|
||||||
|
skipAddress(input)
|
||||||
|
val relay = DatagramSocket(InetSocketAddress(InetAddress.getByName(LOCALHOST), 0))
|
||||||
|
openDatagramSockets.add(relay)
|
||||||
|
|
||||||
|
try {
|
||||||
|
protectAndBind(relay)
|
||||||
|
sendReply(output, REPLY_SUCCEEDED, InetAddress.getByName(LOCALHOST), relay.localPort)
|
||||||
|
|
||||||
|
val udpTask = executor?.submit {
|
||||||
|
relayUdpPackets(relay)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (client.getInputStream().read() != -1) {
|
||||||
|
}
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
udpTask?.cancel(true)
|
||||||
|
} finally {
|
||||||
|
openDatagramSockets.remove(relay)
|
||||||
|
relay.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relayUdpPackets(relay: DatagramSocket) {
|
||||||
|
var clientAddress: InetSocketAddress? = null
|
||||||
|
val buffer = ByteArray(UDP_BUFFER_SIZE)
|
||||||
|
|
||||||
|
while (!relay.isClosed) {
|
||||||
|
try {
|
||||||
|
val packet = DatagramPacket(buffer, buffer.size)
|
||||||
|
relay.receive(packet)
|
||||||
|
val source = InetSocketAddress(packet.address, packet.port)
|
||||||
|
val data = packet.data.copyOfRange(packet.offset, packet.offset + packet.length)
|
||||||
|
|
||||||
|
if (clientAddress == null || source == clientAddress) {
|
||||||
|
clientAddress = source
|
||||||
|
val request = parseUdpRequest(data) ?: continue
|
||||||
|
executor?.execute {
|
||||||
|
relaySingleUdpRequest(relay, source, request)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: SocketException) {
|
||||||
|
break
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun relaySingleUdpRequest(relay: DatagramSocket, client: InetSocketAddress, request: UdpRequest) {
|
||||||
|
val remote = DatagramSocket()
|
||||||
|
openDatagramSockets.add(remote)
|
||||||
|
|
||||||
|
try {
|
||||||
|
protectAndBind(remote)
|
||||||
|
remote.soTimeout = UDP_REPLY_TIMEOUT_MS
|
||||||
|
val targetAddress = InetAddress.getByName(request.host)
|
||||||
|
remote.send(DatagramPacket(request.payload, request.payload.size, targetAddress, request.port))
|
||||||
|
|
||||||
|
val buffer = ByteArray(UDP_BUFFER_SIZE)
|
||||||
|
val reply = DatagramPacket(buffer, buffer.size)
|
||||||
|
remote.receive(reply)
|
||||||
|
|
||||||
|
val response = buildUdpResponse(
|
||||||
|
reply.address,
|
||||||
|
reply.port,
|
||||||
|
reply.data.copyOfRange(reply.offset, reply.offset + reply.length)
|
||||||
|
)
|
||||||
|
relay.send(DatagramPacket(response, response.size, client.address, client.port))
|
||||||
|
} catch (_: SocketTimeoutException) {
|
||||||
|
} catch (_: IOException) {
|
||||||
|
} finally {
|
||||||
|
openDatagramSockets.remove(remote)
|
||||||
|
remote.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseUdpRequest(data: ByteArray): UdpRequest? {
|
||||||
|
if (data.size < 10 || data[0].toInt() != 0 || data[1].toInt() != 0 || data[2].toInt() != 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
var offset = 3
|
||||||
|
val host = when (data[offset++].toInt() and 0xff) {
|
||||||
|
ADDRESS_IPV4 -> {
|
||||||
|
if (data.size < offset + 4 + 2) return null
|
||||||
|
InetAddress.getByAddress(data.copyOfRange(offset, offset + 4)).hostAddress.orEmpty()
|
||||||
|
.also { offset += 4 }
|
||||||
|
}
|
||||||
|
|
||||||
|
ADDRESS_DOMAIN -> {
|
||||||
|
if (data.size < offset + 1) return null
|
||||||
|
val length = data[offset++].toInt() and 0xff
|
||||||
|
if (data.size < offset + length + 2) return null
|
||||||
|
String(data.copyOfRange(offset, offset + length), Charsets.UTF_8)
|
||||||
|
.also { offset += length }
|
||||||
|
}
|
||||||
|
|
||||||
|
ADDRESS_IPV6 -> {
|
||||||
|
if (data.size < offset + 16 + 2) return null
|
||||||
|
InetAddress.getByAddress(data.copyOfRange(offset, offset + 16)).hostAddress.orEmpty()
|
||||||
|
.also { offset += 16 }
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val port = ((data[offset].toInt() and 0xff) shl 8) or (data[offset + 1].toInt() and 0xff)
|
||||||
|
offset += 2
|
||||||
|
return UdpRequest(host, port, data.copyOfRange(offset, data.size))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildUdpResponse(address: InetAddress, port: Int, payload: ByteArray): ByteArray {
|
||||||
|
val addressBytes = address.address
|
||||||
|
val addressType = if (addressBytes.size == 16) ADDRESS_IPV6 else ADDRESS_IPV4
|
||||||
|
return byteArrayOf(0, 0, 0, addressType.toByte()) +
|
||||||
|
addressBytes +
|
||||||
|
byteArrayOf(((port ushr 8) and 0xff).toByte(), (port and 0xff).toByte()) +
|
||||||
|
payload
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun pipeBothWays(client: Socket, remote: Socket) {
|
||||||
|
val pool = executor ?: return
|
||||||
|
val first = pool.submit {
|
||||||
|
copyAndClose(client.getInputStream(), remote.getOutputStream(), client, remote)
|
||||||
|
}
|
||||||
|
val second = pool.submit {
|
||||||
|
copyAndClose(remote.getInputStream(), client.getOutputStream(), remote, client)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
first.get()
|
||||||
|
second.get()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun copyAndClose(input: InputStream, output: OutputStream, source: Socket, target: Socket) {
|
||||||
|
try {
|
||||||
|
input.copyTo(output)
|
||||||
|
} catch (_: IOException) {
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
source.shutdownInput()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
target.shutdownOutput()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAddress(input: InputStream): SocksAddress {
|
||||||
|
return when (val addressType = input.readByte()) {
|
||||||
|
ADDRESS_IPV4 -> {
|
||||||
|
val address = ByteArray(4)
|
||||||
|
input.readFully(address)
|
||||||
|
SocksAddress(InetAddress.getByAddress(address).hostAddress.orEmpty(), input.readPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
ADDRESS_DOMAIN -> {
|
||||||
|
val length = input.readByte()
|
||||||
|
val domain = ByteArray(length)
|
||||||
|
input.readFully(domain)
|
||||||
|
SocksAddress(String(domain, Charsets.UTF_8), input.readPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
ADDRESS_IPV6 -> {
|
||||||
|
val address = ByteArray(16)
|
||||||
|
input.readFully(address)
|
||||||
|
SocksAddress(InetAddress.getByAddress(address).hostAddress.orEmpty(), input.readPort())
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> throw IOException("Unsupported address type: $addressType")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun skipAddress(input: InputStream) {
|
||||||
|
when (input.readByte()) {
|
||||||
|
ADDRESS_IPV4 -> input.skipFully(4)
|
||||||
|
ADDRESS_DOMAIN -> input.skipFully(input.readByte())
|
||||||
|
ADDRESS_IPV6 -> input.skipFully(16)
|
||||||
|
else -> throw IOException("Unsupported address type")
|
||||||
|
}
|
||||||
|
input.skipFully(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendReply(output: OutputStream, reply: Int, address: InetAddress, port: Int) {
|
||||||
|
val addressBytes = if (address.address.size == 16) {
|
||||||
|
InetAddress.getByName(LOCALHOST).address
|
||||||
|
} else {
|
||||||
|
address.address
|
||||||
|
}
|
||||||
|
output.write(
|
||||||
|
byteArrayOf(
|
||||||
|
SOCKS_VERSION.toByte(),
|
||||||
|
reply.toByte(),
|
||||||
|
0,
|
||||||
|
ADDRESS_IPV4.toByte(),
|
||||||
|
addressBytes[0],
|
||||||
|
addressBytes[1],
|
||||||
|
addressBytes[2],
|
||||||
|
addressBytes[3],
|
||||||
|
((port ushr 8) and 0xff).toByte(),
|
||||||
|
(port and 0xff).toByte()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output.flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun protectAndBind(socket: Socket) {
|
||||||
|
if (!vpnService.protect(socket)) {
|
||||||
|
throw IOException("Failed to protect outbound TCP socket")
|
||||||
|
}
|
||||||
|
findUnderlyingNetwork()?.bindSocket(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createOutboundTcpSocket(): Socket {
|
||||||
|
val network = findUnderlyingNetwork()
|
||||||
|
val socket = network?.socketFactory?.createSocket() ?: Socket()
|
||||||
|
try {
|
||||||
|
if (!vpnService.protect(socket)) {
|
||||||
|
throw IOException("Failed to protect outbound TCP socket")
|
||||||
|
}
|
||||||
|
network?.bindSocket(socket)
|
||||||
|
return socket
|
||||||
|
} catch (error: IOException) {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: IOException) {
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun protectAndBind(socket: DatagramSocket) {
|
||||||
|
if (!vpnService.protect(socket)) {
|
||||||
|
throw IOException("Failed to protect outbound UDP socket")
|
||||||
|
}
|
||||||
|
findUnderlyingNetwork()?.bindSocket(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findUnderlyingNetwork(): Network? {
|
||||||
|
val connectivityManager = vpnService.getSystemService(ConnectivityManager::class.java)
|
||||||
|
?: return null
|
||||||
|
return connectivityManager.allNetworks.firstOrNull { network ->
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return@firstOrNull false
|
||||||
|
capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||||
|
!capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readByte(): Int {
|
||||||
|
val value = read()
|
||||||
|
if (value == -1) throw EOFException()
|
||||||
|
return value and 0xff
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readFully(buffer: ByteArray) {
|
||||||
|
var offset = 0
|
||||||
|
while (offset < buffer.size) {
|
||||||
|
val read = read(buffer, offset, buffer.size - offset)
|
||||||
|
if (read == -1) throw EOFException()
|
||||||
|
offset += read
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.skipFully(length: Int) {
|
||||||
|
var remaining = length
|
||||||
|
while (remaining > 0) {
|
||||||
|
val skipped = skip(remaining.toLong()).toInt()
|
||||||
|
if (skipped <= 0) {
|
||||||
|
readByte()
|
||||||
|
remaining--
|
||||||
|
} else {
|
||||||
|
remaining -= skipped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun InputStream.readPort(): Int {
|
||||||
|
return (readByte() shl 8) or readByte()
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class SocksAddress(val host: String, val port: Int)
|
||||||
|
private data class UdpRequest(val host: String, val port: Int, val payload: ByteArray)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val LOCALHOST = "127.0.0.1"
|
||||||
|
const val SOCKS_VERSION = 5
|
||||||
|
const val METHOD_NO_AUTH = 0
|
||||||
|
const val METHOD_NOT_ACCEPTABLE = 0xff
|
||||||
|
const val COMMAND_CONNECT = 1
|
||||||
|
const val COMMAND_UDP_ASSOCIATE = 3
|
||||||
|
const val ADDRESS_IPV4 = 1
|
||||||
|
const val ADDRESS_DOMAIN = 3
|
||||||
|
const val ADDRESS_IPV6 = 4
|
||||||
|
const val REPLY_SUCCEEDED = 0
|
||||||
|
const val REPLY_COMMAND_NOT_SUPPORTED = 7
|
||||||
|
const val REPLY_HOST_UNREACHABLE = 4
|
||||||
|
const val CONNECT_TIMEOUT_MS = 10_000
|
||||||
|
const val UDP_REPLY_TIMEOUT_MS = 10_000
|
||||||
|
const val UDP_BUFFER_SIZE = 65_535
|
||||||
|
const val TAG = "LocalSocksProxy"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,49 @@
|
|||||||
package jp.cyberrex.dummyvpn
|
package jp.cyberrex.dummyvpn
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.VpnService
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import jp.cyberrex.dummyvpn.ui.theme.DummyVPNTheme
|
import jp.cyberrex.dummyvpn.ui.theme.DummyVPNTheme
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@@ -19,29 +52,188 @@ class MainActivity : ComponentActivity() {
|
|||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
setContent {
|
setContent {
|
||||||
DummyVPNTheme {
|
DummyVPNTheme {
|
||||||
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
|
DummyVpnApp()
|
||||||
Greeting(
|
|
||||||
name = "Android",
|
|
||||||
modifier = Modifier.padding(innerPadding)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Greeting(name: String, modifier: Modifier = Modifier) {
|
private fun DummyVpnApp() {
|
||||||
Text(
|
val context = LocalContext.current
|
||||||
text = "Hello $name!",
|
val status by VpnStatusStore.status.collectAsState()
|
||||||
modifier = modifier
|
|
||||||
|
val notificationPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission()
|
||||||
|
) {}
|
||||||
|
val vpnPermissionLauncher = rememberLauncherForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult()
|
||||||
|
) { result ->
|
||||||
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
|
DummyVpnService.start(context)
|
||||||
|
} else {
|
||||||
|
VpnStatusStore.set(VpnStatus.Off)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (isVpnNetworkActive(context)) {
|
||||||
|
VpnStatusStore.set(VpnStatus.On)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DummyVpnScreen(
|
||||||
|
status = status,
|
||||||
|
onToggle = {
|
||||||
|
when (status) {
|
||||||
|
VpnStatus.Off, is VpnStatus.Error -> {
|
||||||
|
if (isVpnNetworkActive(context)) {
|
||||||
|
DummyVpnService.stop(context)
|
||||||
|
} else {
|
||||||
|
VpnStatusStore.set(VpnStatus.Starting)
|
||||||
|
val prepareIntent: Intent? = VpnService.prepare(context)
|
||||||
|
if (prepareIntent != null) {
|
||||||
|
vpnPermissionLauncher.launch(prepareIntent)
|
||||||
|
} else {
|
||||||
|
DummyVpnService.start(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VpnStatus.On -> DummyVpnService.stop(context)
|
||||||
|
VpnStatus.Starting, VpnStatus.Stopping -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun DummyVpnScreen(
|
||||||
|
status: VpnStatus,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val isOn = status == VpnStatus.On
|
||||||
|
val isBusy = status == VpnStatus.Starting || status == VpnStatus.Stopping
|
||||||
|
val buttonColor by animateColorAsState(
|
||||||
|
targetValue = when {
|
||||||
|
isOn -> Color(0xFF2F6F5E)
|
||||||
|
isBusy -> Color(0xFF6B7280)
|
||||||
|
else -> Color(0xFF31343A)
|
||||||
|
},
|
||||||
|
label = "vpnButtonColor"
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(modifier = modifier.fillMaxSize()) { innerPadding ->
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color(0xFFF7F8FA))
|
||||||
|
.padding(horizontal = 28.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "DummyVPN",
|
||||||
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
color = Color(0xFF202329)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = statusText(status),
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = Color(0xFF5F6673)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onToggle,
|
||||||
|
enabled = !isBusy,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(210.dp)
|
||||||
|
.shadow(18.dp, CircleShape),
|
||||||
|
shape = CircleShape,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = buttonColor,
|
||||||
|
contentColor = Color.White,
|
||||||
|
disabledContainerColor = buttonColor,
|
||||||
|
disabledContentColor = Color.White
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Box(contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = buttonText(status),
|
||||||
|
fontSize = 44.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
letterSpacing = 0.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(36.dp))
|
||||||
|
Text(
|
||||||
|
text = detailText(status),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = if (status is VpnStatus.Error) Color(0xFF9B2F2F) else Color(0xFF69717E)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buttonText(status: VpnStatus): String {
|
||||||
|
return when (status) {
|
||||||
|
VpnStatus.On -> "ON"
|
||||||
|
VpnStatus.Off, is VpnStatus.Error -> "OFF"
|
||||||
|
VpnStatus.Starting -> "..."
|
||||||
|
VpnStatus.Stopping -> "..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statusText(status: VpnStatus): String {
|
||||||
|
return when (status) {
|
||||||
|
VpnStatus.On -> "VPN is active"
|
||||||
|
VpnStatus.Off -> "VPN is inactive"
|
||||||
|
VpnStatus.Starting -> "Starting VPN"
|
||||||
|
VpnStatus.Stopping -> "Stopping VPN"
|
||||||
|
is VpnStatus.Error -> "VPN could not start"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detailText(status: VpnStatus): String {
|
||||||
|
return when (status) {
|
||||||
|
VpnStatus.On -> "All apps are routed through the local pass-through VPN."
|
||||||
|
VpnStatus.Off -> "Tap the button to start the pass-through VPN."
|
||||||
|
VpnStatus.Starting -> "Preparing the VPN interface and local proxy."
|
||||||
|
VpnStatus.Stopping -> "Closing the VPN interface and local proxy."
|
||||||
|
is VpnStatus.Error -> status.message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isVpnNetworkActive(context: android.content.Context): Boolean {
|
||||||
|
val connectivityManager = context.getSystemService(ConnectivityManager::class.java)
|
||||||
|
?: return false
|
||||||
|
return connectivityManager.allNetworks.any { network ->
|
||||||
|
connectivityManager.getNetworkCapabilities(network)
|
||||||
|
?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Preview(showBackground = true)
|
@Preview(showBackground = true)
|
||||||
@Composable
|
@Composable
|
||||||
fun GreetingPreview() {
|
private fun DummyVpnScreenPreview() {
|
||||||
DummyVPNTheme {
|
DummyVPNTheme {
|
||||||
Greeting("Android")
|
DummyVpnScreen(status = VpnStatus.Off, onToggle = {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
153
app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt
Normal file
153
app/src/main/java/jp/cyberrex/dummyvpn/Tun2SocksProcess.kt
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package jp.cyberrex.dummyvpn
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.ParcelFileDescriptor
|
||||||
|
import android.system.Os
|
||||||
|
import android.system.OsConstants
|
||||||
|
import android.util.Log
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileNotFoundException
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class Tun2SocksProcess(private val context: Context) {
|
||||||
|
private var process: Process? = null
|
||||||
|
private var logThread: Thread? = null
|
||||||
|
private var watchThread: Thread? = null
|
||||||
|
|
||||||
|
fun start(tunInterface: ParcelFileDescriptor, socksPort: Int) {
|
||||||
|
stop()
|
||||||
|
|
||||||
|
val binary = findBinary()
|
||||||
|
val tunForProcess = ParcelFileDescriptor.dup(tunInterface.fileDescriptor)
|
||||||
|
val tunFd = tunForProcess.detachFd()
|
||||||
|
val command = listOf(
|
||||||
|
binary.absolutePath,
|
||||||
|
"-device",
|
||||||
|
"fd://0",
|
||||||
|
"-mtu",
|
||||||
|
"1500",
|
||||||
|
"-proxy",
|
||||||
|
"socks5://127.0.0.1:$socksPort",
|
||||||
|
"-loglevel",
|
||||||
|
"info"
|
||||||
|
)
|
||||||
|
|
||||||
|
val stdinBackup = Os.dup(STDIN)
|
||||||
|
try {
|
||||||
|
Os.dup2(fileDescriptorOf(tunFd), STDIN_FILENO)
|
||||||
|
process = ProcessBuilder(command)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.redirectInput(ProcessBuilder.Redirect.INHERIT)
|
||||||
|
.start()
|
||||||
|
val startedProcess = process ?: return
|
||||||
|
startLogging(startedProcess)
|
||||||
|
startWatching(startedProcess)
|
||||||
|
} catch (error: Exception) {
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
Os.dup2(stdinBackup, STDIN_FILENO)
|
||||||
|
closeFileDescriptor(stdinBackup)
|
||||||
|
closeDetachedFd(tunFd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() {
|
||||||
|
logThread?.interrupt()
|
||||||
|
logThread = null
|
||||||
|
watchThread?.interrupt()
|
||||||
|
watchThread = null
|
||||||
|
|
||||||
|
process?.destroy()
|
||||||
|
try {
|
||||||
|
if (process?.waitFor(750, TimeUnit.MILLISECONDS) == false) {
|
||||||
|
process?.destroyForcibly()
|
||||||
|
process?.waitFor(750, TimeUnit.MILLISECONDS)
|
||||||
|
}
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
process?.destroyForcibly()
|
||||||
|
}
|
||||||
|
process = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startLogging(startedProcess: Process) {
|
||||||
|
logThread = Thread {
|
||||||
|
try {
|
||||||
|
startedProcess.inputStream.bufferedReader().useLines { lines ->
|
||||||
|
lines.forEach { line ->
|
||||||
|
Log.i(TAG, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
name = "tun2socks-log"
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startWatching(startedProcess: Process) {
|
||||||
|
watchThread = Thread {
|
||||||
|
try {
|
||||||
|
val exitCode = startedProcess.waitFor()
|
||||||
|
if (process == startedProcess) {
|
||||||
|
Log.e(TAG, "tun2socks exited with code $exitCode")
|
||||||
|
VpnStatusStore.set(VpnStatus.Error("tun2socks exited with code $exitCode"))
|
||||||
|
}
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
Thread.currentThread().interrupt()
|
||||||
|
}
|
||||||
|
}.apply {
|
||||||
|
name = "tun2socks-watch"
|
||||||
|
isDaemon = true
|
||||||
|
start()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findBinary(): File {
|
||||||
|
val binary = File(context.applicationInfo.nativeLibraryDir, BINARY_NAME)
|
||||||
|
if (binary.exists() && binary.canExecute()) {
|
||||||
|
return binary
|
||||||
|
}
|
||||||
|
|
||||||
|
throw FileNotFoundException(
|
||||||
|
"tun2socks binary not found. Place it at app/src/main/jniLibs/<abi>/$BINARY_NAME"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeDetachedFd(fd: Int) {
|
||||||
|
try {
|
||||||
|
ParcelFileDescriptor.adoptFd(fd).close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun closeFileDescriptor(fd: java.io.FileDescriptor) {
|
||||||
|
try {
|
||||||
|
Os.close(fd)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fileDescriptorOf(fd: Int): java.io.FileDescriptor {
|
||||||
|
return java.io.FileDescriptor().also { descriptor ->
|
||||||
|
setFileDescriptorInt(descriptor, fd)
|
||||||
|
val flags = Os.fcntlInt(descriptor, OsConstants.F_GETFD, 0)
|
||||||
|
Os.fcntlInt(descriptor, OsConstants.F_SETFD, flags and OsConstants.FD_CLOEXEC.inv())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setFileDescriptorInt(descriptor: java.io.FileDescriptor, fd: Int) {
|
||||||
|
val field = java.io.FileDescriptor::class.java.getDeclaredField("descriptor")
|
||||||
|
field.isAccessible = true
|
||||||
|
field.setInt(descriptor, fd)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "Tun2SocksProcess"
|
||||||
|
const val BINARY_NAME = "libtun2socks.so"
|
||||||
|
const val STDIN_FILENO = 0
|
||||||
|
val STDIN: java.io.FileDescriptor = java.io.FileDescriptor.`in`
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt
Normal file
22
app/src/main/java/jp/cyberrex/dummyvpn/VpnStatus.kt
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package jp.cyberrex.dummyvpn
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
|
sealed interface VpnStatus {
|
||||||
|
data object Off : VpnStatus
|
||||||
|
data object Starting : VpnStatus
|
||||||
|
data object On : VpnStatus
|
||||||
|
data object Stopping : VpnStatus
|
||||||
|
data class Error(val message: String) : VpnStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
object VpnStatusStore {
|
||||||
|
private val mutableStatus = MutableStateFlow<VpnStatus>(VpnStatus.Off)
|
||||||
|
val status: StateFlow<VpnStatus> = mutableStatus.asStateFlow()
|
||||||
|
|
||||||
|
fun set(status: VpnStatus) {
|
||||||
|
mutableStatus.value = status
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@ package jp.cyberrex.dummyvpn.ui.theme
|
|||||||
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
val Purple80 = Color(0xFFD0BCFF)
|
val DeepGreen80 = Color(0xFFA7D7C5)
|
||||||
val PurpleGrey80 = Color(0xFFCCC2DC)
|
val Slate80 = Color(0xFFC8CDD4)
|
||||||
val Pink80 = Color(0xFFEFB8C8)
|
val SoftBlue80 = Color(0xFFB8D3E6)
|
||||||
|
|
||||||
val Purple40 = Color(0xFF6650a4)
|
val DeepGreen40 = Color(0xFF2F6F5E)
|
||||||
val PurpleGrey40 = Color(0xFF625b71)
|
val Slate40 = Color(0xFF4D5663)
|
||||||
val Pink40 = Color(0xFF7D5260)
|
val SoftBlue40 = Color(0xFF406C86)
|
||||||
|
|||||||
@@ -12,15 +12,15 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
|
||||||
private val DarkColorScheme = darkColorScheme(
|
private val DarkColorScheme = darkColorScheme(
|
||||||
primary = Purple80,
|
primary = DeepGreen80,
|
||||||
secondary = PurpleGrey80,
|
secondary = Slate80,
|
||||||
tertiary = Pink80
|
tertiary = SoftBlue80
|
||||||
)
|
)
|
||||||
|
|
||||||
private val LightColorScheme = lightColorScheme(
|
private val LightColorScheme = lightColorScheme(
|
||||||
primary = Purple40,
|
primary = DeepGreen40,
|
||||||
secondary = PurpleGrey40,
|
secondary = Slate40,
|
||||||
tertiary = Pink40
|
tertiary = SoftBlue40
|
||||||
|
|
||||||
/* Other default colors to override
|
/* Other default colors to override
|
||||||
background = Color(0xFFFFFBFE),
|
background = Color(0xFFFFFBFE),
|
||||||
@@ -37,7 +37,7 @@ private val LightColorScheme = lightColorScheme(
|
|||||||
fun DummyVPNTheme(
|
fun DummyVPNTheme(
|
||||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||||
// Dynamic color is available on Android 12+
|
// Dynamic color is available on Android 12+
|
||||||
dynamicColor: Boolean = true,
|
dynamicColor: Boolean = false,
|
||||||
content: @Composable () -> Unit
|
content: @Composable () -> Unit
|
||||||
) {
|
) {
|
||||||
val colorScheme = when {
|
val colorScheme = when {
|
||||||
@@ -55,4 +55,4 @@ fun DummyVPNTheme(
|
|||||||
typography = Typography,
|
typography = Typography,
|
||||||
content = content
|
content = content
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
12
app/src/main/jniLibs/README.md
Normal file
12
app/src/main/jniLibs/README.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
Place ABI-specific tun2socks binaries here and name each executable `libtun2socks.so`.
|
||||||
|
|
||||||
|
Expected paths:
|
||||||
|
|
||||||
|
- `arm64-v8a/libtun2socks.so`
|
||||||
|
- `armeabi-v7a/libtun2socks.so`
|
||||||
|
- `x86/libtun2socks.so`
|
||||||
|
- `x86_64/libtun2socks.so`
|
||||||
|
|
||||||
|
Android does not allow executing files copied into app data directories on modern
|
||||||
|
versions. Packaging the executable as a native library keeps it in an executable
|
||||||
|
location exposed by `applicationInfo.nativeLibraryDir`.
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">DummyVPN</string>
|
<string name="app_name">DummyVPN</string>
|
||||||
</resources>
|
<string name="vpn_notification_channel">VPN status</string>
|
||||||
|
<string name="vpn_notification_title">DummyVPN is active</string>
|
||||||
|
<string name="vpn_notification_text">Traffic is being passed through the local VPN.</string>
|
||||||
|
<string name="vpn_notification_stop">Stop</string>
|
||||||
|
<string name="vpn_error_unknown">Unknown VPN error</string>
|
||||||
|
</resources>
|
||||||
|
|||||||
@@ -2,4 +2,4 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application) apply false
|
alias(libs.plugins.android.application) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user