From 834b504dffc92e25efef4e8f9478b164fc471ef0 Mon Sep 17 00:00:00 2001 From: albexk Date: Tue, 18 Jun 2024 20:46:21 +0300 Subject: [PATCH] Android XRay (#840) * Add XRay module --- CMakeLists.txt | 4 +- client/3rd-prebuilt | 2 +- client/android/AndroidManifest.xml | 30 ++- client/android/build.gradle.kts | 4 +- client/android/gradle/libs.versions.toml | 3 + .../amnezia/vpn/protocol/openvpn/OpenVpn.kt | 14 +- .../protocolApi/src/main/kotlin/Protocol.kt | 9 +- client/android/qt/build.gradle.kts | 2 +- client/android/settings.gradle.kts | 2 + .../src/org/amnezia/vpn/AmneziaActivity.kt | 62 +++-- .../src/org/amnezia/vpn/AmneziaTileService.kt | 65 +++-- .../src/org/amnezia/vpn/AmneziaVpnService.kt | 85 ++++--- .../android/src/org/amnezia/vpn/AwgService.kt | 3 + .../src/org/amnezia/vpn/OpenVpnService.kt | 3 + .../org/amnezia/vpn/ServiceNotification.kt | 23 +- .../android/src/org/amnezia/vpn/VpnProto.kt | 67 +++++ .../src/org/amnezia/vpn/VpnRequestActivity.kt | 25 +- .../android/src/org/amnezia/vpn/VpnState.kt | 66 ++--- .../src/org/amnezia/vpn/XrayService.kt | 3 + .../vpn/protocol/wireguard/Wireguard.kt | 8 +- client/android/xray/build.gradle.kts | 19 ++ client/android/xray/libXray/build.gradle.kts | 6 + client/android/xray/src/main/kotlin/Xray.kt | 237 ++++++++++++++++++ .../xray/src/main/kotlin/XrayConfig.kt | 42 ++++ client/cmake/android.cmake | 3 + client/containers/containers_defs.cpp | 1 + 26 files changed, 637 insertions(+), 151 deletions(-) create mode 100644 client/android/src/org/amnezia/vpn/AwgService.kt create mode 100644 client/android/src/org/amnezia/vpn/OpenVpnService.kt create mode 100644 client/android/src/org/amnezia/vpn/VpnProto.kt create mode 100644 client/android/src/org/amnezia/vpn/XrayService.kt create mode 100644 client/android/xray/build.gradle.kts create mode 100644 client/android/xray/libXray/build.gradle.kts create mode 100644 client/android/xray/src/main/kotlin/Xray.kt create mode 100644 client/android/xray/src/main/kotlin/XrayConfig.kt diff --git a/CMakeLists.txt b/CMakeLists.txt index 7f363dcd..28bb2266 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.25.0 FATAL_ERROR) set(PROJECT AmneziaVPN) -project(${PROJECT} VERSION 4.5.3.0 +project(${PROJECT} VERSION 4.6.0.0 DESCRIPTION "AmneziaVPN" HOMEPAGE_URL "https://amnezia.org/" ) @@ -11,7 +11,7 @@ string(TIMESTAMP CURRENT_DATE "%Y-%m-%d") set(RELEASE_DATE "${CURRENT_DATE}") set(APP_MAJOR_VERSION ${CMAKE_PROJECT_VERSION_MAJOR}.${CMAKE_PROJECT_VERSION_MINOR}.${CMAKE_PROJECT_VERSION_PATCH}) -set(APP_ANDROID_VERSION_CODE 52) +set(APP_ANDROID_VERSION_CODE 53) if(${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(MZ_PLATFORM_NAME "linux") diff --git a/client/3rd-prebuilt b/client/3rd-prebuilt index eb43e90f..ea49bf87 160000 --- a/client/3rd-prebuilt +++ b/client/3rd-prebuilt @@ -1 +1 @@ -Subproject commit eb43e90f389745af6d7ca3be92a96e400ba6dc6c +Subproject commit ea49bf8796afbc5bd70a0f98f4d99c9ea4792d80 diff --git a/client/android/AndroidManifest.xml b/client/android/AndroidManifest.xml index 9637b029..f1d2682b 100644 --- a/client/android/AndroidManifest.xml +++ b/client/android/AndroidManifest.xml @@ -136,8 +136,34 @@ + + + + + + + + + + + + + + , onError: (String) -> Unit) { - super.initialize(context, state, onError) - loadSharedLibrary(context, "ovpn3") - this.context = context + override fun internalInit() { + if (!isInitialized) loadSharedLibrary(context, "ovpn3") + if (this::scope.isInitialized) { + scope.cancel() + } scope = CoroutineScope(Dispatchers.IO) } diff --git a/client/android/protocolApi/src/main/kotlin/Protocol.kt b/client/android/protocolApi/src/main/kotlin/Protocol.kt index e51d0fc1..a475a2fc 100644 --- a/client/android/protocolApi/src/main/kotlin/Protocol.kt +++ b/client/android/protocolApi/src/main/kotlin/Protocol.kt @@ -27,14 +27,21 @@ private const val SPLIT_TUNNEL_EXCLUDE = 2 abstract class Protocol { abstract val statistics: Statistics + protected lateinit var context: Context protected lateinit var state: MutableStateFlow protected lateinit var onError: (String) -> Unit + protected var isInitialized: Boolean = false - open fun initialize(context: Context, state: MutableStateFlow, onError: (String) -> Unit) { + fun initialize(context: Context, state: MutableStateFlow, onError: (String) -> Unit) { + this.context = context this.state = state this.onError = onError + internalInit() + isInitialized = true } + protected abstract fun internalInit() + abstract fun startVpn(config: JSONObject, vpnBuilder: Builder, protect: (Int) -> Boolean) abstract fun stopVpn() diff --git a/client/android/qt/build.gradle.kts b/client/android/qt/build.gradle.kts index 2ec2c941..139adf4f 100644 --- a/client/android/qt/build.gradle.kts +++ b/client/android/qt/build.gradle.kts @@ -21,5 +21,5 @@ android { } dependencies { - implementation(fileTree(mapOf("dir" to "../libs", "include" to listOf("*.jar", "*.aar")))) + implementation(fileTree(mapOf("dir" to "../libs", "include" to listOf("*.jar")))) } diff --git a/client/android/settings.gradle.kts b/client/android/settings.gradle.kts index d270731b..5cfc8314 100644 --- a/client/android/settings.gradle.kts +++ b/client/android/settings.gradle.kts @@ -36,6 +36,8 @@ include(":wireguard") include(":awg") include(":openvpn") include(":cloak") +include(":xray") +include(":xray:libXray") // get values from gradle or local properties val androidBuildToolsVersion: String by gradleProperties diff --git a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt index c9063f22..202fe2e6 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaActivity.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaActivity.kt @@ -34,6 +34,7 @@ import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking @@ -43,6 +44,8 @@ import org.amnezia.vpn.protocol.getStatus import org.amnezia.vpn.qt.QtAndroidController import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Prefs +import org.json.JSONException +import org.json.JSONObject import org.qtproject.qt.android.bindings.QtActivity private const val TAG = "AmneziaActivity" @@ -59,6 +62,7 @@ class AmneziaActivity : QtActivity() { private lateinit var mainScope: CoroutineScope private val qtInitialized = CompletableDeferred() + private var vpnProto: VpnProto? = null private var isWaitingStatus = true private var isServiceConnected = false private var isInBoundState = false @@ -141,6 +145,7 @@ class AmneziaActivity : QtActivity() { override fun onBindingDied(name: ComponentName?) { Log.w(TAG, "Binding to the ${name?.flattenToString()} unexpectedly died") doUnbindService() + QtAndroidController.onServiceDisconnected() doBindService() } } @@ -153,15 +158,20 @@ class AmneziaActivity : QtActivity() { super.onCreate(savedInstanceState) Log.d(TAG, "Create Amnezia activity: $intent") mainScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + val proto = mainScope.async(Dispatchers.IO) { + VpnStateStore.getVpnState().vpnProto + } vpnServiceMessenger = IpcMessenger( "VpnService", onDeadObjectException = { doUnbindService() + QtAndroidController.onServiceDisconnected() doBindService() } ) registerBroadcastReceivers() intent?.let(::processIntent) + runBlocking { vpnProto = proto.await() } } private fun registerBroadcastReceivers() { @@ -209,13 +219,18 @@ class AmneziaActivity : QtActivity() { Log.d(TAG, "Start Amnezia activity") mainScope.launch { qtInitialized.await() - doBindService() + vpnProto?.let { proto -> + if (AmneziaVpnService.isRunning(applicationContext, proto.processName)) { + doBindService() + } + } } } override fun onStop() { Log.d(TAG, "Stop Amnezia activity") doUnbindService() + QtAndroidController.onServiceDisconnected() super.onStop() } @@ -269,10 +284,12 @@ class AmneziaActivity : QtActivity() { @MainThread private fun doBindService() { Log.d(TAG, "Bind service") - Intent(this, AmneziaVpnService::class.java).also { - bindService(it, serviceConnection, BIND_ABOVE_CLIENT and BIND_AUTO_CREATE) + vpnProto?.let { proto -> + Intent(this, proto.serviceClass).also { + bindService(it, serviceConnection, BIND_ABOVE_CLIENT and BIND_AUTO_CREATE) + } + isInBoundState = true } - isInBoundState = true } @MainThread @@ -280,7 +297,6 @@ class AmneziaActivity : QtActivity() { if (isInBoundState) { Log.d(TAG, "Unbind service") isWaitingStatus = true - QtAndroidController.onServiceDisconnected() isServiceConnected = false vpnServiceMessenger.send(Action.UNREGISTER_CLIENT, activityMessenger) vpnServiceMessenger.reset() @@ -365,13 +381,31 @@ class AmneziaActivity : QtActivity() { @MainThread private fun startVpn(vpnConfig: String) { - if (isServiceConnected) { - connectToVpn(vpnConfig) - } else { + getVpnProto(vpnConfig)?.let { proto -> + Log.d(TAG, "Proto from config: $proto, current proto: $vpnProto") + if (isServiceConnected) { + if (proto == vpnProto) { + connectToVpn(vpnConfig) + return + } + doUnbindService() + } + vpnProto = proto isWaitingStatus = false - startVpnService(vpnConfig) + startVpnService(vpnConfig, proto) doBindService() - } + } ?: QtAndroidController.onServiceError() + } + + private fun getVpnProto(vpnConfig: String): VpnProto? = try { + require(vpnConfig.isNotBlank()) { "Blank VPN config" } + VpnProto.get(JSONObject(vpnConfig).getString("protocol")) + } catch (e: JSONException) { + Log.e(TAG, "Invalid VPN config json format: ${e.message}") + null + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Protocol not found: ${e.message}") + null } private fun connectToVpn(vpnConfig: String) { @@ -383,15 +417,15 @@ class AmneziaActivity : QtActivity() { } } - private fun startVpnService(vpnConfig: String) { - Log.d(TAG, "Start VPN service") - Intent(this, AmneziaVpnService::class.java).apply { + private fun startVpnService(vpnConfig: String, proto: VpnProto) { + Log.d(TAG, "Start VPN service: $proto") + Intent(this, proto.serviceClass).apply { putExtra(MSG_VPN_CONFIG, vpnConfig) }.also { try { ContextCompat.startForegroundService(this, it) } catch (e: SecurityException) { - Log.e(TAG, "Failed to start AmneziaVpnService: $e") + Log.e(TAG, "Failed to start ${proto.serviceClass.simpleName}: $e") QtAndroidController.onServiceError() } } diff --git a/client/android/src/org/amnezia/vpn/AmneziaTileService.kt b/client/android/src/org/amnezia/vpn/AmneziaTileService.kt index 1d13feac..32d5710d 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaTileService.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaTileService.kt @@ -39,6 +39,9 @@ class AmneziaTileService : TileService() { @Volatile private var isServiceConnected = false + + @Volatile + private var vpnProto: VpnProto? = null private var isInBoundState = false @Volatile private var isVpnConfigExists = false @@ -94,16 +97,21 @@ class AmneziaTileService : TileService() { override fun onStartListening() { super.onStartListening() - Log.d(TAG, "Start listening") - if (AmneziaVpnService.isRunning(applicationContext)) { - Log.d(TAG, "Vpn service is running") - doBindService() - } else { - Log.d(TAG, "Vpn service is not running") - isServiceConnected = false - updateVpnState(DISCONNECTED) + scope.launch { + Log.d(TAG, "Start listening") + vpnProto = VpnStateStore.getVpnState().vpnProto + vpnProto.also { proto -> + if (proto != null && AmneziaVpnService.isRunning(applicationContext, proto.processName)) { + Log.d(TAG, "Vpn service is running") + doBindService() + } else { + Log.d(TAG, "Vpn service is not running") + isServiceConnected = false + updateVpnState(DISCONNECTED) + } + } + vpnStateListeningJob = launchVpnStateListening() } - vpnStateListeningJob = launchVpnStateListening() } override fun onStopListening() { @@ -124,7 +132,7 @@ class AmneziaTileService : TileService() { } private fun onClickInternal() { - if (isVpnConfigExists) { + if (isVpnConfigExists && vpnProto != null) { Log.d(TAG, "Change VPN state") if (qsTile.state == Tile.STATE_INACTIVE) { Log.d(TAG, "Start VPN") @@ -147,10 +155,12 @@ class AmneziaTileService : TileService() { private fun doBindService() { Log.d(TAG, "Bind service") - Intent(this, AmneziaVpnService::class.java).also { - bindService(it, serviceConnection, BIND_ABOVE_CLIENT) + vpnProto?.let { proto -> + Intent(this, proto.serviceClass).also { + bindService(it, serviceConnection, BIND_ABOVE_CLIENT) + } + isInBoundState = true } - isInBoundState = true } private fun doUnbindService() { @@ -180,6 +190,7 @@ class AmneziaTileService : TileService() { if (VpnService.prepare(applicationContext) != null) { Intent(this, VpnRequestActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(EXTRA_PROTOCOL, vpnProto) }.also { startActivityAndCollapseCompat(it) } @@ -189,14 +200,16 @@ class AmneziaTileService : TileService() { } private fun startVpnService() { - try { - ContextCompat.startForegroundService( - applicationContext, - Intent(this, AmneziaVpnService::class.java) - ) - } catch (e: SecurityException) { - Log.e(TAG, "Failed to start AmneziaVpnService: $e") - } + vpnProto?.let { proto -> + try { + ContextCompat.startForegroundService( + applicationContext, + Intent(this, proto.serviceClass) + ) + } catch (e: SecurityException) { + Log.e(TAG, "Failed to start ${proto.serviceClass.simpleName}: $e") + } + } ?: Log.e(TAG, "Failed to start vpn service: vpnProto is null") } private fun connectToVpn() = vpnServiceMessenger.send(Action.CONNECT) @@ -220,11 +233,8 @@ class AmneziaTileService : TileService() { } } - private fun updateVpnState(state: ProtocolState) { - scope.launch { - VpnStateStore.store { it.copy(protocolState = state) } - } - } + private fun updateVpnState(state: ProtocolState) = + scope.launch { VpnStateStore.store { it.copy(protocolState = state) } } private fun launchVpnStateListening() = scope.launch { VpnStateStore.dataFlow().collectLatest(::updateTile) } @@ -232,9 +242,10 @@ class AmneziaTileService : TileService() { private fun updateTile(vpnState: VpnState) { Log.d(TAG, "Update tile: $vpnState") isVpnConfigExists = vpnState.serverName != null + vpnProto = vpnState.vpnProto val tile = qsTile ?: return tile.apply { - label = vpnState.serverName ?: DEFAULT_TILE_LABEL + label = (vpnState.serverName ?: DEFAULT_TILE_LABEL) + (vpnProto?.let { " ${it.label}" } ?: "") when (val protocolState = vpnState.protocolState) { CONNECTED -> { state = Tile.STATE_ACTIVE diff --git a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt index 89c53481..b30f1503 100644 --- a/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt +++ b/client/android/src/org/amnezia/vpn/AmneziaVpnService.kt @@ -1,5 +1,6 @@ package org.amnezia.vpn +import android.annotation.SuppressLint import android.app.ActivityManager import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND_SERVICE import android.app.NotificationManager @@ -39,7 +40,6 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import org.amnezia.vpn.protocol.BadConfigException import org.amnezia.vpn.protocol.LoadLibraryException -import org.amnezia.vpn.protocol.Protocol import org.amnezia.vpn.protocol.ProtocolState.CONNECTED import org.amnezia.vpn.protocol.ProtocolState.CONNECTING import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED @@ -48,11 +48,7 @@ import org.amnezia.vpn.protocol.ProtocolState.RECONNECTING import org.amnezia.vpn.protocol.ProtocolState.UNKNOWN import org.amnezia.vpn.protocol.VpnException import org.amnezia.vpn.protocol.VpnStartException -import org.amnezia.vpn.protocol.awg.Awg -import org.amnezia.vpn.protocol.cloak.Cloak -import org.amnezia.vpn.protocol.openvpn.OpenVpn import org.amnezia.vpn.protocol.putStatus -import org.amnezia.vpn.protocol.wireguard.Wireguard import org.amnezia.vpn.util.Log import org.amnezia.vpn.util.Prefs import org.amnezia.vpn.util.net.NetworkState @@ -63,6 +59,7 @@ import org.json.JSONObject private const val TAG = "AmneziaVpnService" const val ACTION_DISCONNECT = "org.amnezia.vpn.action.disconnect" +const val ACTION_CONNECT = "org.amnezia.vpn.action.connect" const val MSG_VPN_CONFIG = "VPN_CONFIG" const val MSG_ERROR = "ERROR" @@ -73,19 +70,18 @@ const val AFTER_PERMISSION_CHECK = "AFTER_PERMISSION_CHECK" private const val PREFS_CONFIG_KEY = "LAST_CONF" private const val PREFS_SERVER_NAME = "LAST_SERVER_NAME" private const val PREFS_SERVER_INDEX = "LAST_SERVER_INDEX" -private const val PROCESS_NAME = "org.amnezia.vpn:amneziaVpnService" // private const val STATISTICS_SENDING_TIMEOUT = 1000L private const val TRAFFIC_STATS_UPDATE_TIMEOUT = 1000L private const val DISCONNECT_TIMEOUT = 5000L private const val STOP_SERVICE_TIMEOUT = 5000L -class AmneziaVpnService : VpnService() { +@SuppressLint("Registered") +open class AmneziaVpnService : VpnService() { private lateinit var mainScope: CoroutineScope private lateinit var connectionScope: CoroutineScope private var isServiceBound = false - private var protocol: Protocol? = null - private val protocolCache = mutableMapOf() + private var vpnProto: VpnProto? = null private var protocolState = MutableStateFlow(UNKNOWN) private var serverName: String? = null private var serverIndex: Int = -1 @@ -105,7 +101,7 @@ class AmneziaVpnService : VpnService() { // private var statisticsSendingJob: Job? = null private lateinit var networkState: NetworkState private lateinit var trafficStats: TrafficStats - private var disconnectReceiver: BroadcastReceiver? = null + private var controlReceiver: BroadcastReceiver? = null private var notificationStateReceiver: BroadcastReceiver? = null private var screenOnReceiver: BroadcastReceiver? = null private var screenOffReceiver: BroadcastReceiver? = null @@ -116,7 +112,6 @@ class AmneziaVpnService : VpnService() { private val connectionExceptionHandler = CoroutineExceptionHandler { _, e -> protocolState.value = DISCONNECTED - protocol = null when (e) { is IllegalArgumentException, is VpnStartException, @@ -227,7 +222,8 @@ class AmneziaVpnService : VpnService() { connect(intent?.getStringExtra(MSG_VPN_CONFIG)) } ServiceCompat.startForeground( - this, NOTIFICATION_ID, serviceNotification.buildNotification(serverName, protocolState.value), + this, NOTIFICATION_ID, + serviceNotification.buildNotification(serverName, vpnProto?.label, protocolState.value), foregroundServiceTypeCompat ) return START_REDELIVER_INTENT @@ -292,9 +288,17 @@ class AmneziaVpnService : VpnService() { private fun registerBroadcastReceivers() { Log.d(TAG, "Register broadcast receivers") - disconnectReceiver = registerBroadcastReceiver(ACTION_DISCONNECT, ContextCompat.RECEIVER_NOT_EXPORTED) { - Log.d(TAG, "Broadcast request received: $ACTION_DISCONNECT") - disconnect() + controlReceiver = registerBroadcastReceiver( + arrayOf(ACTION_CONNECT, ACTION_DISCONNECT), ContextCompat.RECEIVER_NOT_EXPORTED + ) { + it?.action?.let { action -> + Log.d(TAG, "Broadcast request received: $action") + when (action) { + ACTION_CONNECT -> connect() + ACTION_DISCONNECT -> disconnect() + else -> Log.w(TAG, "Unknown action received: $action") + } + } } notificationStateReceiver = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { @@ -340,10 +344,10 @@ class AmneziaVpnService : VpnService() { private fun unregisterBroadcastReceivers() { Log.d(TAG, "Unregister broadcast receivers") - unregisterBroadcastReceiver(disconnectReceiver) + unregisterBroadcastReceiver(controlReceiver) unregisterBroadcastReceiver(notificationStateReceiver) unregisterScreenStateBroadcastReceivers() - disconnectReceiver = null + controlReceiver = null notificationStateReceiver = null } @@ -356,7 +360,7 @@ class AmneziaVpnService : VpnService() { protocolState.drop(1).collect { protocolState -> Log.d(TAG, "Protocol state changed: $protocolState") - serviceNotification.updateNotification(serverName, protocolState) + serviceNotification.updateNotification(serverName, vpnProto?.label, protocolState) clientMessengers.send { ServiceEvent.STATUS_CHANGED.packToMessage { @@ -364,7 +368,7 @@ class AmneziaVpnService : VpnService() { } } - VpnStateStore.store { VpnState(protocolState, serverName, serverIndex) } + VpnStateStore.store { VpnState(protocolState, serverName, serverIndex, vpnProto) } when (protocolState) { CONNECTED -> { @@ -421,7 +425,7 @@ class AmneziaVpnService : VpnService() { @MainThread private fun enableNotification() { registerScreenStateBroadcastReceivers() - serviceNotification.updateNotification(serverName, protocolState.value) + serviceNotification.updateNotification(serverName, vpnProto?.label, protocolState.value) launchTrafficStatsUpdate() } @@ -484,8 +488,6 @@ class AmneziaVpnService : VpnService() { Log.d(TAG, "Start VPN connection") - protocolState.value = CONNECTING - val config = parseConfigToJson(vpnConfig) saveServerData(config) if (config == null) { @@ -494,6 +496,16 @@ class AmneziaVpnService : VpnService() { return } + try { + vpnProto = VpnProto.get(config.getString("protocol")) + } catch (e: Exception) { + onError("Invalid VPN config: ${e.message}") + protocolState.value = DISCONNECTED + return + } + + protocolState.value = CONNECTING + if (!checkPermission()) { protocolState.value = DISCONNECTED return @@ -503,8 +515,10 @@ class AmneziaVpnService : VpnService() { disconnectionJob?.join() disconnectionJob = null - protocol = getProtocol(config.getString("protocol")) - protocol?.startVpn(config, Builder(), ::protect) + vpnProto?.protocol?.let { protocol -> + protocol.initialize(applicationContext, protocolState, ::onError) + protocol.startVpn(config, Builder(), ::protect) + } } } @@ -520,8 +534,8 @@ class AmneziaVpnService : VpnService() { connectionJob?.join() connectionJob = null - protocol?.stopVpn() - protocol = null + vpnProto?.protocol?.stopVpn() + try { withTimeout(DISCONNECT_TIMEOUT) { // waiting for disconnect state @@ -543,22 +557,10 @@ class AmneziaVpnService : VpnService() { protocolState.value = RECONNECTING connectionJob = connectionScope.launch { - protocol?.reconnectVpn(Builder()) + vpnProto?.protocol?.reconnectVpn(Builder()) } } - @MainThread - private fun getProtocol(protocolName: String): Protocol = - protocolCache[protocolName] - ?: when (protocolName) { - "wireguard" -> Wireguard() - "awg" -> Awg() - "openvpn" -> OpenVpn() - "cloak" -> Cloak() - else -> throw IllegalArgumentException("Protocol '$protocolName' not found") - }.apply { initialize(applicationContext, protocolState, ::onError) } - .also { protocolCache[protocolName] = it } - /** * Utils methods */ @@ -603,6 +605,7 @@ class AmneziaVpnService : VpnService() { if (prepare(applicationContext) != null) { Intent(this, VpnRequestActivity::class.java).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + putExtra(EXTRA_PROTOCOL, vpnProto) }.also { startActivity(it) } @@ -612,9 +615,9 @@ class AmneziaVpnService : VpnService() { } companion object { - fun isRunning(context: Context): Boolean = + fun isRunning(context: Context, processName: String): Boolean = context.getSystemService()!!.runningAppProcesses.any { - it.processName == PROCESS_NAME && it.importance <= IMPORTANCE_FOREGROUND_SERVICE + it.processName == processName && it.importance <= IMPORTANCE_FOREGROUND_SERVICE } } } diff --git a/client/android/src/org/amnezia/vpn/AwgService.kt b/client/android/src/org/amnezia/vpn/AwgService.kt new file mode 100644 index 00000000..ebdbe543 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/AwgService.kt @@ -0,0 +1,3 @@ +package org.amnezia.vpn + +class AwgService : AmneziaVpnService() diff --git a/client/android/src/org/amnezia/vpn/OpenVpnService.kt b/client/android/src/org/amnezia/vpn/OpenVpnService.kt new file mode 100644 index 00000000..72c8bab1 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/OpenVpnService.kt @@ -0,0 +1,3 @@ +package org.amnezia.vpn + +class OpenVpnService : AmneziaVpnService() diff --git a/client/android/src/org/amnezia/vpn/ServiceNotification.kt b/client/android/src/org/amnezia/vpn/ServiceNotification.kt index efdd04d3..f4707731 100644 --- a/client/android/src/org/amnezia/vpn/ServiceNotification.kt +++ b/client/android/src/org/amnezia/vpn/ServiceNotification.kt @@ -59,14 +59,14 @@ class ServiceNotification(private val context: Context) { formatSpeedString(rxString, txString) } - fun buildNotification(serverName: String?, state: ProtocolState): Notification { + fun buildNotification(serverName: String?, protocol: String?, state: ProtocolState): Notification { val speedString = if (state == CONNECTED) zeroSpeed else null Log.d(TAG, "Build notification: $serverName, $state") return notificationBuilder .setSmallIcon(R.drawable.ic_amnezia_round) - .setContentTitle(serverName ?: "AmneziaVPN") + .setContentTitle((serverName ?: "AmneziaVPN") + (protocol?.let { " $it" } ?: "")) .setContentText(context.getString(state)) .setSubText(speedString) .setWhen(System.currentTimeMillis()) @@ -96,10 +96,10 @@ class ServiceNotification(private val context: Context) { } @SuppressLint("MissingPermission") - fun updateNotification(serverName: String?, state: ProtocolState) { + fun updateNotification(serverName: String?, protocol: String?, state: ProtocolState) { if (context.isNotificationPermissionGranted()) { Log.d(TAG, "Update notification: $serverName, $state") - notificationManager.notify(NOTIFICATION_ID, buildNotification(serverName, state)) + notificationManager.notify(NOTIFICATION_ID, buildNotification(serverName, protocol, state)) } } @@ -125,7 +125,7 @@ class ServiceNotification(private val context: Context) { context, DISCONNECT_REQUEST_CODE, Intent(ACTION_DISCONNECT).apply { - setPackage("org.amnezia.vpn") + setPackage(context.packageName) }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) @@ -135,10 +135,12 @@ class ServiceNotification(private val context: Context) { DISCONNECTED -> { Action( 0, context.getString(R.string.connect), - createServicePendingIntent( + PendingIntent.getBroadcast( context, CONNECT_REQUEST_CODE, - Intent(context, AmneziaVpnService::class.java), + Intent(ACTION_CONNECT).apply { + setPackage(context.packageName) + }, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT ) ) @@ -148,13 +150,6 @@ class ServiceNotification(private val context: Context) { } } - private val createServicePendingIntent: (Context, Int, Intent, Int) -> PendingIntent = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - PendingIntent::getForegroundService - } else { - PendingIntent::getService - } - companion object { fun createNotificationChannel(context: Context) { with(NotificationManagerCompat.from(context)) { diff --git a/client/android/src/org/amnezia/vpn/VpnProto.kt b/client/android/src/org/amnezia/vpn/VpnProto.kt new file mode 100644 index 00000000..508ce226 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/VpnProto.kt @@ -0,0 +1,67 @@ +package org.amnezia.vpn + +import org.amnezia.vpn.protocol.Protocol +import org.amnezia.vpn.protocol.awg.Awg +import org.amnezia.vpn.protocol.cloak.Cloak +import org.amnezia.vpn.protocol.openvpn.OpenVpn +import org.amnezia.vpn.protocol.wireguard.Wireguard +import org.amnezia.vpn.protocol.xray.Xray + +enum class VpnProto( + val label: String, + val processName: String, + val serviceClass: Class +) { + WIREGUARD( + "WireGuard", + "org.amnezia.vpn:amneziaAwgService", + AwgService::class.java + ) { + override fun createProtocol(): Protocol = Wireguard() + }, + + AWG( + "AmneziaWG", + "org.amnezia.vpn:amneziaAwgService", + AwgService::class.java + ) { + override fun createProtocol(): Protocol = Awg() + }, + + OPENVPN( + "OpenVPN", + "org.amnezia.vpn:amneziaOpenVpnService", + OpenVpnService::class.java + ) { + override fun createProtocol(): Protocol = OpenVpn() + }, + + CLOAK( + "Cloak", + "org.amnezia.vpn:amneziaOpenVpnService", + OpenVpnService::class.java + ) { + override fun createProtocol(): Protocol = Cloak() + }, + + XRAY( + "XRay", + "org.amnezia.vpn:amneziaXrayService", + XrayService::class.java + ) { + override fun createProtocol(): Protocol = Xray() + }; + + private var _protocol: Protocol? = null + val protocol: Protocol + get() { + if (_protocol == null) _protocol = createProtocol() + return _protocol ?: throw AssertionError("Set to null by another thread") + } + + protected abstract fun createProtocol(): Protocol + + companion object { + fun get(protocolName: String): VpnProto = VpnProto.valueOf(protocolName.uppercase()) + } +} \ No newline at end of file diff --git a/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt b/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt index 12d3fb3d..c24f5a19 100644 --- a/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt +++ b/client/android/src/org/amnezia/vpn/VpnRequestActivity.kt @@ -7,6 +7,7 @@ import android.content.Intent import android.content.res.Configuration.UI_MODE_NIGHT_MASK import android.content.res.Configuration.UI_MODE_NIGHT_YES import android.net.VpnService +import android.os.Build import android.os.Bundle import android.provider.Settings import android.widget.Toast @@ -18,9 +19,11 @@ import androidx.core.content.getSystemService import org.amnezia.vpn.util.Log private const val TAG = "VpnRequestActivity" +const val EXTRA_PROTOCOL = "PROTOCOL" class VpnRequestActivity : ComponentActivity() { + private var vpnProto: VpnProto? = null private var userPresentReceiver: BroadcastReceiver? = null private val requestLauncher = registerForActivityResult(StartActivityForResult(), ::checkRequestResult) @@ -28,6 +31,12 @@ class VpnRequestActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Log.d(TAG, "Start request activity") + vpnProto = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.extras?.getSerializable(EXTRA_PROTOCOL, VpnProto::class.java) + } else { + @Suppress("DEPRECATION") + intent.extras?.getSerializable(EXTRA_PROTOCOL) as VpnProto + } val requestIntent = VpnService.prepare(applicationContext) if (requestIntent != null) { if (getSystemService()!!.isKeyguardLocked) { @@ -66,10 +75,18 @@ class VpnRequestActivity : ComponentActivity() { private fun onPermissionGranted() { Toast.makeText(this, resources.getString(R.string.vpnGranted), Toast.LENGTH_LONG).show() - Intent(applicationContext, AmneziaVpnService::class.java).apply { - putExtra(AFTER_PERMISSION_CHECK, true) - }.also { - ContextCompat.startForegroundService(this, it) + vpnProto?.let { proto -> + Intent(applicationContext, proto.serviceClass).apply { + putExtra(AFTER_PERMISSION_CHECK, true) + }.also { + ContextCompat.startForegroundService(this, it) + } + } ?: run { + Intent(this, AmneziaActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + }.also { + startActivity(it) + } } } diff --git a/client/android/src/org/amnezia/vpn/VpnState.kt b/client/android/src/org/amnezia/vpn/VpnState.kt index fbc4ef59..94039dc1 100644 --- a/client/android/src/org/amnezia/vpn/VpnState.kt +++ b/client/android/src/org/amnezia/vpn/VpnState.kt @@ -1,19 +1,22 @@ package org.amnezia.vpn import android.app.Application +import androidx.datastore.core.CorruptionException import androidx.datastore.core.MultiProcessDataStoreFactory import androidx.datastore.core.Serializer +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler import androidx.datastore.dataStoreFile -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream import java.io.InputStream -import java.io.ObjectInputStream -import java.io.ObjectOutputStream import java.io.OutputStream -import java.io.Serializable -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.withContext +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerializationException +import kotlinx.serialization.decodeFromByteArray +import kotlinx.serialization.encodeToByteArray +import kotlinx.serialization.protobuf.ProtoBuf import org.amnezia.vpn.protocol.ProtocolState import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED import org.amnezia.vpn.util.Log @@ -21,13 +24,14 @@ import org.amnezia.vpn.util.Log private const val TAG = "VpnState" private const val STORE_FILE_NAME = "vpnState" +@Serializable data class VpnState( val protocolState: ProtocolState, val serverName: String? = null, - val serverIndex: Int = -1 -) : Serializable { + val serverIndex: Int = -1, + val vpnProto: VpnProto? = null +) { companion object { - private const val serialVersionUID: Long = -1760654961004181606 val defaultState: VpnState = VpnState(DISCONNECTED) } } @@ -37,7 +41,11 @@ object VpnStateStore { private val dataStore = MultiProcessDataStoreFactory.create( serializer = VpnStateSerializer(), - produceFile = { app.dataStoreFile(STORE_FILE_NAME) } + produceFile = { app.dataStoreFile(STORE_FILE_NAME) }, + corruptionHandler = ReplaceFileCorruptionHandler { e -> + Log.e(TAG, "VpnState DataStore corrupted: $e") + VpnState.defaultState + } ) fun init(app: Application) { @@ -45,36 +53,36 @@ object VpnStateStore { this.app = app } - fun dataFlow(): Flow = dataStore.data + fun dataFlow(): Flow = dataStore.data.catch { e -> + Log.e(TAG, "Failed to read VpnState from store: ${e.message}") + emit(VpnState.defaultState) + } + + suspend fun getVpnState(): VpnState = dataFlow().firstOrNull() ?: VpnState.defaultState suspend fun store(f: (vpnState: VpnState) -> VpnState) { try { dataStore.updateData(f) - } catch (e : Exception) { + } catch (e: Exception) { Log.e(TAG, "Failed to store VpnState: $e") + Log.w(TAG, "Remove DataStore file") + app.dataStoreFile(STORE_FILE_NAME).delete() } } } +@OptIn(ExperimentalSerializationApi::class) private class VpnStateSerializer : Serializer { override val defaultValue: VpnState = VpnState.defaultState - override suspend fun readFrom(input: InputStream): VpnState { - return withContext(Dispatchers.IO) { - val bios = ByteArrayInputStream(input.readBytes()) - ObjectInputStream(bios).use { - it.readObject() as VpnState - } - } + override suspend fun readFrom(input: InputStream): VpnState = try { + ProtoBuf.decodeFromByteArray(input.readBytes()) + } catch (e: SerializationException) { + Log.e(TAG, "Failed to deserialize data: $e") + throw CorruptionException("Failed to deserialize data", e) } - override suspend fun writeTo(t: VpnState, output: OutputStream) { - withContext(Dispatchers.IO) { - val baos = ByteArrayOutputStream() - ObjectOutputStream(baos).use { - it.writeObject(t) - } - output.write(baos.toByteArray()) - } - } + @Suppress("BlockingMethodInNonBlockingContext") + override suspend fun writeTo(t: VpnState, output: OutputStream) = + output.write(ProtoBuf.encodeToByteArray(t)) } diff --git a/client/android/src/org/amnezia/vpn/XrayService.kt b/client/android/src/org/amnezia/vpn/XrayService.kt new file mode 100644 index 00000000..2efcb4c9 --- /dev/null +++ b/client/android/src/org/amnezia/vpn/XrayService.kt @@ -0,0 +1,3 @@ +package org.amnezia.vpn + +class XrayService : AmneziaVpnService() diff --git a/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/Wireguard.kt b/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/Wireguard.kt index 09482918..690510eb 100644 --- a/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/Wireguard.kt +++ b/client/android/wireguard/src/main/kotlin/org/amnezia/vpn/protocol/wireguard/Wireguard.kt @@ -1,12 +1,9 @@ package org.amnezia.vpn.protocol.wireguard -import android.content.Context import android.net.VpnService.Builder import java.util.TreeMap -import kotlinx.coroutines.flow.MutableStateFlow import org.amnezia.awg.GoBackend import org.amnezia.vpn.protocol.Protocol -import org.amnezia.vpn.protocol.ProtocolState import org.amnezia.vpn.protocol.ProtocolState.CONNECTED import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED import org.amnezia.vpn.protocol.Statistics @@ -78,9 +75,8 @@ open class Wireguard : Protocol() { } } - override fun initialize(context: Context, state: MutableStateFlow, onError: (String) -> Unit) { - super.initialize(context, state, onError) - loadSharedLibrary(context, "wg-go") + override fun internalInit() { + if (!isInitialized) loadSharedLibrary(context, "wg-go") } override fun startVpn(config: JSONObject, vpnBuilder: Builder, protect: (Int) -> Boolean) { diff --git a/client/android/xray/build.gradle.kts b/client/android/xray/build.gradle.kts new file mode 100644 index 00000000..f21a12a3 --- /dev/null +++ b/client/android/xray/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id(libs.plugins.android.library.get().pluginId) + id(libs.plugins.kotlin.android.get().pluginId) +} + +kotlin { + jvmToolchain(17) +} + +android { + namespace = "org.amnezia.vpn.protocol.xray" +} + +dependencies { + compileOnly(project(":utils")) + compileOnly(project(":protocolApi")) + implementation(project(":xray:libXray")) + implementation(libs.kotlinx.coroutines) +} diff --git a/client/android/xray/libXray/build.gradle.kts b/client/android/xray/libXray/build.gradle.kts new file mode 100644 index 00000000..99b9db36 --- /dev/null +++ b/client/android/xray/libXray/build.gradle.kts @@ -0,0 +1,6 @@ +@file:Suppress("UnstableApiUsage") + +configurations { + maybeCreate("default") +} +artifacts.add("default", file("libxray.aar")) diff --git a/client/android/xray/src/main/kotlin/Xray.kt b/client/android/xray/src/main/kotlin/Xray.kt new file mode 100644 index 00000000..b4d0b51f --- /dev/null +++ b/client/android/xray/src/main/kotlin/Xray.kt @@ -0,0 +1,237 @@ +package org.amnezia.vpn.protocol.xray + +import android.content.Context +import android.net.VpnService.Builder +import java.io.File +import java.io.IOException +import go.Seq +import org.amnezia.vpn.protocol.Protocol +import org.amnezia.vpn.protocol.ProtocolState.CONNECTED +import org.amnezia.vpn.protocol.ProtocolState.DISCONNECTED +import org.amnezia.vpn.protocol.Statistics +import org.amnezia.vpn.protocol.VpnStartException +import org.amnezia.vpn.protocol.xray.libXray.DialerController +import org.amnezia.vpn.protocol.xray.libXray.LibXray +import org.amnezia.vpn.protocol.xray.libXray.Logger +import org.amnezia.vpn.protocol.xray.libXray.Tun2SocksConfig +import org.amnezia.vpn.util.Log +import org.amnezia.vpn.util.net.InetNetwork +import org.amnezia.vpn.util.net.parseInetAddress +import org.json.JSONObject + +/** + * Config example: + * { + * "appSplitTunnelType": 0, + * "config_version": 0, + * "description": "Server 1", + * "dns1": "1.1.1.1", + * "dns2": "1.0.0.1", + * "hostName": "100.100.100.0", + * "protocol": "xray", + * "splitTunnelApps": [], + * "splitTunnelSites": [], + * "splitTunnelType": 0, + * "xray_config_data": { + * "inbounds": [ + * { + * "listen": "127.0.0.1", + * "port": 8080, + * "protocol": "socks", + * "settings": { + * "udp": true + * } + * } + * ], + * "log": { + * "loglevel": "error" + * }, + * "outbounds": [ + * { + * "protocol": "vless", + * "settings": { + * "vnext": [ + * { + * "address": "100.100.100.0", + * "port": 443, + * "users": [ + * { + * "encryption": "none", + * "flow": "xtls-rprx-vision", + * "id": "id" + * } + * ] + * } + * ] + * }, + * "streamSettings": { + * "network": "tcp", + * "realitySettings": { + * "fingerprint": "chrome", + * "publicKey": "publicKey", + * "serverName": "google.com", + * "shortId": "id", + * "spiderX": "" + * }, + * "security": "reality" + * } + * } + * ] + * } + * } + * + */ + +private const val TAG = "Xray" +private const val LIBXRAY_TAG = "libXray" + +class Xray : Protocol() { + + private var isRunning: Boolean = false + override val statistics: Statistics = Statistics.EMPTY_STATISTICS + + override fun internalInit() { + Seq.setContext(context) + if (!isInitialized) { + LibXray.initLogger(object : Logger { + override fun warning(s: String) = Log.w(LIBXRAY_TAG, s) + + override fun error(s: String) = Log.e(LIBXRAY_TAG, s) + + override fun write(msg: ByteArray): Long { + Log.w(LIBXRAY_TAG, String(msg)) + return msg.size.toLong() + } + }).isNotNullOrBlank { err -> + Log.w(TAG, "Failed to initialize logger: $err") + } + } + } + + override fun startVpn(config: JSONObject, vpnBuilder: Builder, protect: (Int) -> Boolean) { + if (isRunning) { + Log.w(TAG, "XRay already running") + return + } + + val xrayJsonConfig = config.getJSONObject("xray_config_data") + val xrayConfig = parseConfig(config, xrayJsonConfig) + + // for debug + // xrayJsonConfig.getJSONObject("log").put("loglevel", "debug") + xrayJsonConfig.getJSONObject("log").put("loglevel", "warning") + // disable access log + xrayJsonConfig.getJSONObject("log").put("access", "none") + + // replace socks address + // (xrayJsonConfig.getJSONArray("inbounds")[0] as JSONObject).put("listen", "::1") + + start(xrayConfig, xrayJsonConfig.toString(), vpnBuilder, protect) + state.value = CONNECTED + isRunning = true + } + + private fun parseConfig(config: JSONObject, xrayJsonConfig: JSONObject): XrayConfig { + return XrayConfig.build { + addAddress(XrayConfig.DEFAULT_IPV4_ADDRESS) + + config.optString("dns1").let { + if (it.isNotBlank()) addDnsServer(parseInetAddress(it)) + } + + config.optString("dns2").let { + if (it.isNotBlank()) addDnsServer(parseInetAddress(it)) + } + + addRoute(InetNetwork("0.0.0.0", 0)) + addRoute(InetNetwork("2000::0", 3)) + config.getString("hostName").let { + excludeRoute(InetNetwork(it, 32)) + } + + config.optString("mtu").let { + if (it.isNotBlank()) setMtu(it.toInt()) + } + + val socksConfig = xrayJsonConfig.getJSONArray("inbounds")[0] as JSONObject + socksConfig.getInt("port").let { setSocksPort(it) } + + configSplitTunneling(config) + configAppSplitTunneling(config) + } + } + + private fun start(config: XrayConfig, configJson: String, vpnBuilder: Builder, protect: (Int) -> Boolean) { + buildVpnInterface(config, vpnBuilder) + + DialerController { protect(it.toInt()) }.also { + LibXray.registerDialerController(it).isNotNullOrBlank { err -> + throw VpnStartException("Failed to register dialer controller: $err") + } + LibXray.registerListenerController(it).isNotNullOrBlank { err -> + throw VpnStartException("Failed to register listener controller: $err") + } + } + + vpnBuilder.establish().use { tunFd -> + if (tunFd == null) { + throw VpnStartException("Create VPN interface: permission not granted or revoked") + } + Log.d(TAG, "Run tun2Socks") + runTun2Socks(config, tunFd.detachFd()) + + Log.d(TAG, "Run XRay") + Log.i(TAG, "xray ${LibXray.xrayVersion()}") + val assetsPath = context.getDir("assets", Context.MODE_PRIVATE).absolutePath + LibXray.initXray(assetsPath) + val geoDir = File(assetsPath, "geo").absolutePath + val configPath = File(context.cacheDir, "config.json") + Log.d(TAG, "xray.location.asset: $geoDir") + Log.d(TAG, "config: $configPath") + try { + configPath.writeText(configJson) + } catch (e: IOException) { + LibXray.stopTun2Socks() + throw VpnStartException("Failed to write xray config: ${e.message}") + } + LibXray.runXray(geoDir, configPath.absolutePath, config.maxMemory).isNotNullOrBlank { err -> + LibXray.stopTun2Socks() + throw VpnStartException("Failed to start xray: $err") + } + } + } + + override fun stopVpn() { + LibXray.stopXray().isNotNullOrBlank { err -> + Log.e(TAG, "Failed to stop XRay: $err") + } + LibXray.stopTun2Socks().isNotNullOrBlank { err -> + Log.e(TAG, "Failed to stop tun2Socks: $err") + } + + isRunning = false + state.value = DISCONNECTED + } + + override fun reconnectVpn(vpnBuilder: Builder) { + state.value = CONNECTED + } + + private fun runTun2Socks(config: XrayConfig, fd: Int) { + val tun2SocksConfig = Tun2SocksConfig().apply { + mtu = config.mtu.toLong() + proxy = "socks5://127.0.0.1:${config.socksPort}" + device = "fd://$fd" + logLevel = "warning" + } + LibXray.startTun2Socks(tun2SocksConfig, fd.toLong()).isNotNullOrBlank { err -> + throw VpnStartException("Failed to start tun2socks: $err") + } + } +} + +private fun String?.isNotNullOrBlank(block: (String) -> Unit) { + if (!this.isNullOrBlank()) { + block(this) + } +} diff --git a/client/android/xray/src/main/kotlin/XrayConfig.kt b/client/android/xray/src/main/kotlin/XrayConfig.kt new file mode 100644 index 00000000..821a1c2f --- /dev/null +++ b/client/android/xray/src/main/kotlin/XrayConfig.kt @@ -0,0 +1,42 @@ +package org.amnezia.vpn.protocol.xray + +import org.amnezia.vpn.protocol.ProtocolConfig +import org.amnezia.vpn.util.net.InetNetwork + +private const val XRAY_DEFAULT_MTU = 1500 +private const val XRAY_DEFAULT_MAX_MEMORY: Long = 50 shl 20 // 50 MB + +class XrayConfig protected constructor( + protocolConfigBuilder: ProtocolConfig.Builder, + val socksPort: Int, + val maxMemory: Long, +) : ProtocolConfig(protocolConfigBuilder) { + + protected constructor(builder: Builder) : this( + builder, + builder.socksPort, + builder.maxMemory + ) + + class Builder : ProtocolConfig.Builder(false) { + internal var socksPort: Int = 0 + private set + + internal var maxMemory: Long = XRAY_DEFAULT_MAX_MEMORY + private set + + override var mtu: Int = XRAY_DEFAULT_MTU + + fun setSocksPort(port: Int) = apply { socksPort = port } + + fun setMaxMemory(maxMemory: Long) = apply { this.maxMemory = maxMemory } + + override fun build(): XrayConfig = configBuild().run { XrayConfig(this@Builder) } + } + + companion object { + internal val DEFAULT_IPV4_ADDRESS: InetNetwork = InetNetwork("10.0.42.2", 30) + + inline fun build(block: Builder.() -> Unit): XrayConfig = Builder().apply(block).build() + } +} diff --git a/client/cmake/android.cmake b/client/cmake/android.cmake index c39642ff..13c357bd 100644 --- a/client/cmake/android.cmake +++ b/client/cmake/android.cmake @@ -52,3 +52,6 @@ foreach(abi IN ITEMS ${QT_ANDROID_ABIS}) ${CMAKE_CURRENT_SOURCE_DIR}/3rd-prebuilt/3rd-prebuilt/libssh/android/${abi}/libssh.so ) endforeach() + +file(COPY ${CMAKE_CURRENT_SOURCE_DIR}/3rd-prebuilt/3rd-prebuilt/xray/android/libxray.aar + DESTINATION ${CMAKE_CURRENT_SOURCE_DIR}/android/xray/libXray) diff --git a/client/containers/containers_defs.cpp b/client/containers/containers_defs.cpp index 3c2a3861..8276bc93 100644 --- a/client/containers/containers_defs.cpp +++ b/client/containers/containers_defs.cpp @@ -305,6 +305,7 @@ bool ContainerProps::isSupportedByCurrentPlatform(DockerContainer c) case DockerContainer::ShadowSocks: return false; case DockerContainer::Awg: return true; case DockerContainer::Cloak: return true; + case DockerContainer::Xray: return true; default: return false; }