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/amnezia_application.cpp b/client/amnezia_application.cpp index db4061eb..eb1eab45 100644 --- a/client/amnezia_application.cpp +++ b/client/amnezia_application.cpp @@ -351,6 +351,9 @@ void AmneziaApplication::initModels() m_sftpConfigModel.reset(new SftpConfigModel(this)); m_engine->rootContext()->setContextProperty("SftpConfigModel", m_sftpConfigModel.get()); + m_socks5ConfigModel.reset(new Socks5ProxyConfigModel(this)); + m_engine->rootContext()->setContextProperty("Socks5ProxyConfigModel", m_socks5ConfigModel.get()); + m_clientManagementModel.reset(new ClientManagementModel(m_settings, this)); m_engine->rootContext()->setContextProperty("ClientManagementModel", m_clientManagementModel.get()); connect(m_clientManagementModel.get(), &ClientManagementModel::adminConfigRevoked, m_serversModel.get(), diff --git a/client/amnezia_application.h b/client/amnezia_application.h index 5561d7c7..b15d55d7 100644 --- a/client/amnezia_application.h +++ b/client/amnezia_application.h @@ -41,6 +41,7 @@ #include "ui/models/protocols_model.h" #include "ui/models/servers_model.h" #include "ui/models/services/sftpConfigModel.h" +#include "ui/models/services/socks5ProxyConfigModel.h" #include "ui/models/sites_model.h" #include "ui/models/clientManagementModel.h" #include "ui/models/appSplitTunnelingModel.h" @@ -114,6 +115,7 @@ private: #endif QScopedPointer m_sftpConfigModel; + QScopedPointer m_socks5ConfigModel; QSharedPointer m_vpnConnection; QThread m_vpnConnectionThread; 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/res/mipmap-hdpi/icon.png b/client/android/res/mipmap-hdpi/icon.png index d0f20363..f1bf806b 100644 Binary files a/client/android/res/mipmap-hdpi/icon.png and b/client/android/res/mipmap-hdpi/icon.png differ diff --git a/client/android/res/mipmap-hdpi/icon_round.png b/client/android/res/mipmap-hdpi/icon_round.png index 418a71cc..7a798c39 100644 Binary files a/client/android/res/mipmap-hdpi/icon_round.png and b/client/android/res/mipmap-hdpi/icon_round.png differ diff --git a/client/android/res/mipmap-ldpi/icon.png b/client/android/res/mipmap-ldpi/icon.png index 00b978f5..354d642d 100644 Binary files a/client/android/res/mipmap-ldpi/icon.png and b/client/android/res/mipmap-ldpi/icon.png differ diff --git a/client/android/res/mipmap-ldpi/icon_round.png b/client/android/res/mipmap-ldpi/icon_round.png index 371994ed..40b69128 100644 Binary files a/client/android/res/mipmap-ldpi/icon_round.png and b/client/android/res/mipmap-ldpi/icon_round.png differ diff --git a/client/android/res/mipmap-mdpi/icon.png b/client/android/res/mipmap-mdpi/icon.png index e23a94a9..7a47bbfd 100644 Binary files a/client/android/res/mipmap-mdpi/icon.png and b/client/android/res/mipmap-mdpi/icon.png differ diff --git a/client/android/res/mipmap-mdpi/icon_round.png b/client/android/res/mipmap-mdpi/icon_round.png index 0ed71d4c..0278a784 100644 Binary files a/client/android/res/mipmap-mdpi/icon_round.png and b/client/android/res/mipmap-mdpi/icon_round.png differ diff --git a/client/android/res/mipmap-xhdpi/icon.png b/client/android/res/mipmap-xhdpi/icon.png index 0c5408f8..488456f1 100644 Binary files a/client/android/res/mipmap-xhdpi/icon.png and b/client/android/res/mipmap-xhdpi/icon.png differ diff --git a/client/android/res/mipmap-xhdpi/icon_round.png b/client/android/res/mipmap-xhdpi/icon_round.png index a3e18823..46987223 100644 Binary files a/client/android/res/mipmap-xhdpi/icon_round.png and b/client/android/res/mipmap-xhdpi/icon_round.png differ diff --git a/client/android/res/mipmap-xxhdpi/icon.png b/client/android/res/mipmap-xxhdpi/icon.png index 0f94f39e..7700462d 100644 Binary files a/client/android/res/mipmap-xxhdpi/icon.png and b/client/android/res/mipmap-xxhdpi/icon.png differ diff --git a/client/android/res/mipmap-xxhdpi/icon_round.png b/client/android/res/mipmap-xxhdpi/icon_round.png index ca2a6362..610cd43c 100644 Binary files a/client/android/res/mipmap-xxhdpi/icon_round.png and b/client/android/res/mipmap-xxhdpi/icon_round.png differ diff --git a/client/android/res/mipmap-xxxhdpi/icon.png b/client/android/res/mipmap-xxxhdpi/icon.png index 671b0ff9..42127cf2 100644 Binary files a/client/android/res/mipmap-xxxhdpi/icon.png and b/client/android/res/mipmap-xxxhdpi/icon.png differ diff --git a/client/android/res/mipmap-xxxhdpi/icon_round.png b/client/android/res/mipmap-xxxhdpi/icon_round.png index b2b33777..35e69325 100644 Binary files a/client/android/res/mipmap-xxxhdpi/icon_round.png and b/client/android/res/mipmap-xxxhdpi/icon_round.png differ 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 4a248260..2f2f8367 100644 --- a/client/containers/containers_defs.cpp +++ b/client/containers/containers_defs.cpp @@ -69,6 +69,8 @@ QVector ContainerProps::protocolsForContainer(amnezia::DockerCon case DockerContainer::Sftp: return { Proto::Sftp }; + case DockerContainer::Socks5Proxy: return { Proto::Socks5Proxy }; + default: return { defaultProtocol(container) }; } } @@ -98,7 +100,8 @@ QMap ContainerProps::containerHumanNames() { DockerContainer::TorWebSite, QObject::tr("Website in Tor network") }, { DockerContainer::Dns, QObject::tr("AmneziaDNS") }, - { DockerContainer::Sftp, QObject::tr("SFTP file sharing service") } }; + { DockerContainer::Sftp, QObject::tr("SFTP file sharing service") }, + { DockerContainer::Socks5Proxy, QObject::tr("SOCKS5 proxy server") } }; } QMap ContainerProps::containerDescriptions() @@ -131,7 +134,9 @@ QMap ContainerProps::containerDescriptions() { DockerContainer::Dns, QObject::tr("Replace the current DNS server with your own. This will increase your privacy level.") }, { DockerContainer::Sftp, - QObject::tr("Create a file vault on your server to securely store and transfer files.") } }; + QObject::tr("Create a file vault on your server to securely store and transfer files.") }, + { DockerContainer::Socks5Proxy, + QObject::tr("") } }; } QMap ContainerProps::containerDetailedDescriptions() @@ -239,7 +244,8 @@ QMap ContainerProps::containerDetailedDescriptions() QObject::tr("After installation, Amnezia will create a\n\n file storage on your server. " "You will be able to access it using\n FileZilla or other SFTP clients, " "as well as mount the disk on your device to access\n it directly from your device.\n\n" - "For more detailed information, you can\n find it in the support section under \"Create SFTP file storage.\" ") } + "For more detailed information, you can\n find it in the support section under \"Create SFTP file storage.\" ") }, + { DockerContainer::Socks5Proxy, QObject::tr("SOCKS5 proxy server") } }; } @@ -264,6 +270,7 @@ Proto ContainerProps::defaultProtocol(DockerContainer c) case DockerContainer::TorWebSite: return Proto::TorWebSite; case DockerContainer::Dns: return Proto::Dns; case DockerContainer::Sftp: return Proto::Sftp; + case DockerContainer::Socks5Proxy: return Proto::Socks5Proxy; default: return Proto::Any; } } @@ -297,6 +304,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; } @@ -366,6 +374,7 @@ bool ContainerProps::isShareable(DockerContainer container) case DockerContainer::TorWebSite: return false; case DockerContainer::Dns: return false; case DockerContainer::Sftp: return false; + case DockerContainer::Socks5Proxy: return false; default: return true; } } diff --git a/client/containers/containers_defs.h b/client/containers/containers_defs.h index f80cc097..a63e217b 100644 --- a/client/containers/containers_defs.h +++ b/client/containers/containers_defs.h @@ -28,7 +28,8 @@ namespace amnezia // non-vpn TorWebSite, Dns, - Sftp + Sftp, + Socks5Proxy }; Q_ENUM_NS(DockerContainer) } // namespace ContainerEnumNS diff --git a/client/core/controllers/apiController.cpp b/client/core/controllers/apiController.cpp index 1a8dc8eb..35b459be 100644 --- a/client/core/controllers/apiController.cpp +++ b/client/core/controllers/apiController.cpp @@ -40,6 +40,28 @@ void ApiController::processApiConfig(const QString &protocol, const ApiControlle return; } else if (protocol == configKey::awg) { config.replace("$WIREGUARD_CLIENT_PRIVATE_KEY", apiPayloadData.wireGuardClientPrivKey); + auto serverConfig = QJsonDocument::fromJson(config.toUtf8()).object(); + auto containers = serverConfig.value(config_key::containers).toArray(); + if (containers.isEmpty()) { + return; + } + auto container = containers.at(0).toObject(); + QString containerName = ContainerProps::containerTypeToString(DockerContainer::Awg); + auto containerConfig = container.value(containerName).toObject(); + auto protocolConfig = QJsonDocument::fromJson(containerConfig.value(config_key::last_config).toString().toUtf8()).object(); + containerConfig[config_key::junkPacketCount] = protocolConfig.value(config_key::junkPacketCount); + containerConfig[config_key::junkPacketMinSize] = protocolConfig.value(config_key::junkPacketMinSize); + containerConfig[config_key::junkPacketMaxSize] = protocolConfig.value(config_key::junkPacketMaxSize); + containerConfig[config_key::initPacketJunkSize] = protocolConfig.value(config_key::initPacketJunkSize); + containerConfig[config_key::responsePacketJunkSize] = protocolConfig.value(config_key::responsePacketJunkSize); + containerConfig[config_key::initPacketMagicHeader] = protocolConfig.value(config_key::initPacketMagicHeader); + containerConfig[config_key::responsePacketMagicHeader] = protocolConfig.value(config_key::responsePacketMagicHeader); + containerConfig[config_key::underloadPacketMagicHeader] = protocolConfig.value(config_key::underloadPacketMagicHeader); + containerConfig[config_key::transportPacketMagicHeader] = protocolConfig.value(config_key::transportPacketMagicHeader); + container[containerName] = containerConfig; + containers.replace(0, container); + serverConfig[config_key::containers] = containers; + config = QString(QJsonDocument(serverConfig).toJson()); } return; } diff --git a/client/core/controllers/serverController.cpp b/client/core/controllers/serverController.cpp index 12ede703..081d86d6 100644 --- a/client/core/controllers/serverController.cpp +++ b/client/core/controllers/serverController.cpp @@ -106,7 +106,7 @@ ErrorCode ServerController::runContainerScript(const ServerCredentials &credenti if (e) return e; - QString runner = QString("sudo docker exec -i $CONTAINER_NAME bash %1 ").arg(fileName); + QString runner = QString("sudo docker exec -i $CONTAINER_NAME sh %1 ").arg(fileName); e = runScript(credentials, replaceVars(runner, genVarsForScript(credentials, container)), cbReadStdOut, cbReadStdErr); QString remover = QString("sudo docker exec -i $CONTAINER_NAME rm %1 ").arg(fileName); @@ -376,6 +376,10 @@ bool ServerController::isReinstallContainerRequired(DockerContainer container, c return true; } + if (container == DockerContainer::Socks5Proxy) { + return true; + } + return false; } @@ -516,6 +520,7 @@ ServerController::Vars ServerController::genVarsForScript(const ServerCredential const QJsonObject &amneziaWireguarConfig = config.value(ProtocolProps::protoToString(Proto::Awg)).toObject(); const QJsonObject &xrayConfig = config.value(ProtocolProps::protoToString(Proto::Xray)).toObject(); const QJsonObject &sftpConfig = config.value(ProtocolProps::protoToString(Proto::Sftp)).toObject(); + const QJsonObject &socks5ProxyConfig = config.value(ProtocolProps::protoToString(Proto::Socks5Proxy)).toObject(); Vars vars; @@ -613,6 +618,14 @@ ServerController::Vars ServerController::genVarsForScript(const ServerCredential vars.append({ { "$UNDERLOAD_PACKET_MAGIC_HEADER", amneziaWireguarConfig.value(config_key::underloadPacketMagicHeader).toString() } }); vars.append({ { "$TRANSPORT_PACKET_MAGIC_HEADER", amneziaWireguarConfig.value(config_key::transportPacketMagicHeader).toString() } }); + // Socks5 proxy vars + vars.append({ { "$SOCKS5_PROXY_PORT", socks5ProxyConfig.value(config_key::port).toString(protocols::socks5Proxy::defaultPort) } }); + auto username = socks5ProxyConfig.value(config_key:: userName).toString(); + auto password = socks5ProxyConfig.value(config_key::password).toString(); + QString socks5user = (!username.isEmpty() && !password.isEmpty()) ? QString("users %1:CL:%2").arg(username, password) : ""; + vars.append({ { "$SOCKS5_USER", socks5user } }); + vars.append({ { "$SOCKS5_AUTH_TYPE", socks5user.isEmpty() ? "none" : "strong" } }); + QString serverIp = NetworkUtilities::getIPAddress(credentials.hostName); if (!serverIp.isEmpty()) { vars.append({ { "$SERVER_IP_ADDRESS", serverIp } }); diff --git a/client/core/scripts_registry.cpp b/client/core/scripts_registry.cpp index 4e720845..95b5df4a 100644 --- a/client/core/scripts_registry.cpp +++ b/client/core/scripts_registry.cpp @@ -18,6 +18,7 @@ QString amnezia::scriptFolder(amnezia::DockerContainer container) case DockerContainer::TorWebSite: return QLatin1String("website_tor"); case DockerContainer::Dns: return QLatin1String("dns"); case DockerContainer::Sftp: return QLatin1String("sftp"); + case DockerContainer::Socks5Proxy: return QLatin1String("socks5_proxy"); default: return QString(); } } diff --git a/client/protocols/protocols_defs.cpp b/client/protocols/protocols_defs.cpp index 951e3c01..9be5a75f 100644 --- a/client/protocols/protocols_defs.cpp +++ b/client/protocols/protocols_defs.cpp @@ -77,7 +77,8 @@ QMap ProtocolProps::protocolHumanNames() { Proto::TorWebSite, "Website in Tor network" }, { Proto::Dns, "DNS Service" }, - { Proto::Sftp, QObject::tr("SFTP service") } }; + { Proto::Sftp, QObject::tr("SFTP service") }, + { Proto::Socks5Proxy, QObject::tr("SOCKS5 proxy server") } }; } QMap ProtocolProps::protocolDescriptions() @@ -102,6 +103,7 @@ amnezia::ServiceType ProtocolProps::protocolService(Proto p) case Proto::TorWebSite: return ServiceType::Other; case Proto::Dns: return ServiceType::Other; case Proto::Sftp: return ServiceType::Other; + case Proto::Socks5Proxy: return ServiceType::Other; default: return ServiceType::Other; } } @@ -113,6 +115,7 @@ int ProtocolProps::getPortForInstall(Proto p) case WireGuard: case ShadowSocks: case OpenVpn: + case Socks5Proxy: return QRandomGenerator::global()->bounded(30000, 50000); default: return defaultPort(p); @@ -135,6 +138,7 @@ int ProtocolProps::defaultPort(Proto p) case Proto::TorWebSite: return -1; case Proto::Dns: return 53; case Proto::Sftp: return 222; + case Proto::Socks5Proxy: return 38080; default: return -1; } } @@ -154,6 +158,7 @@ bool ProtocolProps::defaultPortChangeable(Proto p) case Proto::TorWebSite: return false; case Proto::Dns: return false; case Proto::Sftp: return true; + case Proto::Socks5Proxy: return true; default: return false; } } @@ -175,6 +180,7 @@ TransportProto ProtocolProps::defaultTransportProto(Proto p) case Proto::TorWebSite: return TransportProto::Tcp; case Proto::Dns: return TransportProto::Udp; case Proto::Sftp: return TransportProto::Tcp; + case Proto::Socks5Proxy: return TransportProto::Tcp; } } @@ -195,6 +201,7 @@ bool ProtocolProps::defaultTransportProtoChangeable(Proto p) case Proto::TorWebSite: return false; case Proto::Dns: return false; case Proto::Sftp: return false; + case Proto::Socks5Proxy: return false; default: return false; } return false; diff --git a/client/protocols/protocols_defs.h b/client/protocols/protocols_defs.h index c98735b0..56be0d7d 100644 --- a/client/protocols/protocols_defs.h +++ b/client/protocols/protocols_defs.h @@ -84,6 +84,7 @@ namespace amnezia constexpr char awg[] = "awg"; constexpr char xray[] = "xray"; constexpr char ssxray[] = "ssxray"; + constexpr char socks5proxy[] = "socks5proxy"; constexpr char configVersion[] = "config_version"; @@ -216,6 +217,14 @@ namespace amnezia constexpr char defaultUnderloadPacketMagicHeader[] = "1766607858"; } + namespace socks5Proxy + { + constexpr char defaultUserName[] = "proxy_user"; + constexpr char defaultPort[] = "38080"; + + constexpr char proxyConfigPath[] = "/usr/local/3proxy/conf/3proxy.cfg"; + } + } // namespace protocols namespace ProtocolEnumNS @@ -244,7 +253,8 @@ namespace amnezia // non-vpn TorWebSite, Dns, - Sftp + Sftp, + Socks5Proxy }; Q_ENUM_NS(Proto) diff --git a/client/resources.qrc b/client/resources.qrc index 49fd66d3..84296462 100644 --- a/client/resources.qrc +++ b/client/resources.qrc @@ -198,7 +198,7 @@ ui/qml/Pages2/PageProtocolOpenVpnSettings.qml ui/qml/Pages2/PageProtocolShadowSocksSettings.qml ui/qml/Pages2/PageProtocolCloakSettings.qml - ui/qml/Pages2/PageProtocolXraySettings.qml + ui/qml/Pages2/PageProtocolXraySettings.qml ui/qml/Pages2/PageProtocolRaw.qml ui/qml/Pages2/PageSettingsLogging.qml ui/qml/Pages2/PageServiceSftpSettings.qml @@ -239,5 +239,10 @@ images/controls/alert-circle.svg images/controls/file-check-2.svg ui/qml/Controls2/WarningType.qml + ui/qml/Pages2/PageServiceSocksProxySettings.qml + server_scripts/socks5_proxy/run_container.sh + server_scripts/socks5_proxy/Dockerfile + server_scripts/socks5_proxy/configure_container.sh + server_scripts/socks5_proxy/start.sh diff --git a/client/server_scripts/socks5_proxy/Dockerfile b/client/server_scripts/socks5_proxy/Dockerfile new file mode 100644 index 00000000..7a38682f --- /dev/null +++ b/client/server_scripts/socks5_proxy/Dockerfile @@ -0,0 +1,10 @@ +FROM 3proxy/3proxy:latest + +LABEL maintainer="AmneziaVPN" + +RUN mkdir -p /opt/amnezia +RUN echo -e "#!/bin/bash\ntail -f /dev/null" > /opt/amnezia/start.sh +RUN chmod a+x /opt/amnezia/start.sh + +ENTRYPOINT [ "/bin/sh", "/opt/amnezia/start.sh" ] +CMD [ "" ] \ No newline at end of file diff --git a/client/server_scripts/socks5_proxy/configure_container.sh b/client/server_scripts/socks5_proxy/configure_container.sh new file mode 100644 index 00000000..d271b65e --- /dev/null +++ b/client/server_scripts/socks5_proxy/configure_container.sh @@ -0,0 +1,12 @@ +#!/bin/sh + +echo -e "#!/bin/3proxy" > /usr/local/3proxy/conf/3proxy.cfg +echo -e "config /usr/local/3proxy/conf/3proxy.cfg" >> /usr/local/3proxy/conf/3proxy.cfg +echo -e "timeouts 1 5 30 60 180 1800 15 60" >> /usr/local/3proxy/conf/3proxy.cfg + +echo -e "$SOCKS5_USER" >> /usr/local/3proxy/conf/3proxy.cfg + +echo -e "log /usr/local/3proxy/logs/3proxy.log" >> /usr/local/3proxy/conf/3proxy.cfg +echo -e "logformat \"-\\\"\"+_G{\"\"time_unix\"\":%t, \"\"proxy\"\":{\"\"type:\"\":\"\"%N\"\", \"\"port\"\":%p}, \"\"error\"\":{\"\"code\"\":\"\"%E\"\"}, \"\"auth\"\":{\"\"user\"\":\"\"%U\"\"}, \"\"client\"\":{\"\"ip\"\":\"\"%C\"\", \"\"port\"\":%c}, \"\"server\"\":{\"\"ip\"\":\"\"%R\"\", \"\"port\"\":%r}, \"\"bytes\"\":{\"\"sent\"\":%O, \"\"received\"\":%I}, \"\"request\"\":{\"\"hostname\"\":\"\"%n\"\"}, \"\"message\"\":\"\"%T\"\"}\"" >> /usr/local/3proxy/conf/3proxy.cfg +echo -e "auth $SOCKS5_AUTH_TYPE" >> /usr/local/3proxy/conf/3proxy.cfg +echo -e "socks -p$SOCKS5_PROXY_PORT" >> /usr/local/3proxy/conf/3proxy.cfg \ No newline at end of file diff --git a/client/server_scripts/socks5_proxy/run_container.sh b/client/server_scripts/socks5_proxy/run_container.sh new file mode 100644 index 00000000..38ff863a --- /dev/null +++ b/client/server_scripts/socks5_proxy/run_container.sh @@ -0,0 +1,5 @@ +sudo docker run -d \ +--restart always \ +-p $SOCKS5_PROXY_PORT:$SOCKS5_PROXY_PORT/tcp \ +--name $CONTAINER_NAME \ +$CONTAINER_NAME diff --git a/client/server_scripts/socks5_proxy/start.sh b/client/server_scripts/socks5_proxy/start.sh new file mode 100644 index 00000000..98555d4e --- /dev/null +++ b/client/server_scripts/socks5_proxy/start.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +# This scripts copied from Amnezia client to Docker container to /opt/amnezia and launched every time container starts + +echo "Container startup" + +/bin/3proxy /usr/local/3proxy/conf/3proxy.cfg \ No newline at end of file diff --git a/client/translations/amneziavpn_hi_IN.ts b/client/translations/amneziavpn_hi_IN.ts index ae746ce8..68f324c6 100644 --- a/client/translations/amneziavpn_hi_IN.ts +++ b/client/translations/amneziavpn_hi_IN.ts @@ -1144,7 +1144,7 @@ Already installed containers were found on the server. All installed containers Executable file (*.*) - निष्पादनीय फाइल (*।*) + निष्पादनीय फाइल (*.*) @@ -1276,7 +1276,7 @@ Already installed containers were found on the server. All installed containers Backup files (*.backup) - बैकअप फ़ाइलें (*.बैकअप) + बैकअप फ़ाइलें (*.backup) @@ -1480,7 +1480,7 @@ Already installed containers were found on the server. All installed containers Logs files (*.log) - लॉग फ़ाइलें (*.लॉग) + लॉग फ़ाइलें (*.log) diff --git a/client/ui/controllers/installController.cpp b/client/ui/controllers/installController.cpp index e743d22c..514091d4 100644 --- a/client/ui/controllers/installController.cpp +++ b/client/ui/controllers/installController.cpp @@ -123,6 +123,9 @@ void InstallController::install(DockerContainer container, int port, TransportPr } else if (container == DockerContainer::Sftp) { containerConfig.insert(config_key::userName, protocols::sftp::defaultUserName); containerConfig.insert(config_key::password, Utils::getRandomString(10)); + } else if (container == DockerContainer::Socks5Proxy) { + containerConfig.insert(config_key::userName, protocols::socks5Proxy::defaultUserName); + containerConfig.insert(config_key::password, Utils::getRandomString(10)); } config.insert(config_key::container, ContainerProps::containerToString(container)); @@ -362,7 +365,7 @@ ErrorCode InstallController::getAlreadyInstalledContainers(const ServerCredentia if (containerInfo.isEmpty()) { continue; } - const static QRegularExpression containerAndPortRegExp("(amnezia[-a-z]*).*?:([0-9]*)->[0-9]*/(udp|tcp).*"); + const static QRegularExpression containerAndPortRegExp("(amnezia[-a-z0-9]*).*?:([0-9]*)->[0-9]*/(udp|tcp).*"); QRegularExpressionMatch containerAndPortMatch = containerAndPortRegExp.match(containerInfo); if (containerAndPortMatch.hasMatch()) { QString name = containerAndPortMatch.captured(1); @@ -427,6 +430,20 @@ ErrorCode InstallController::getAlreadyInstalledContainers(const ServerCredentia containerConfig.insert(config_key::userName, userName); containerConfig.insert(config_key::password, password); + } else if (protocol == Proto::Socks5Proxy) { + QString proxyConfig = serverController->getTextFileFromContainer(container, credentials, + protocols::socks5Proxy::proxyConfigPath, errorCode); + + const static QRegularExpression usernameAndPasswordRegExp("users (\\w+):CL:(\\w+)"); + QRegularExpressionMatch usernameAndPasswordMatch = usernameAndPasswordRegExp.match(proxyConfig); + + if (usernameAndPasswordMatch.hasMatch()) { + QString userName = usernameAndPasswordMatch.captured(1); + QString password = usernameAndPasswordMatch.captured(2); + + containerConfig.insert(config_key::userName, userName); + containerConfig.insert(config_key::password, password); + } } config.insert(config_key::container, ContainerProps::containerToString(container)); @@ -603,6 +620,10 @@ void InstallController::clearCachedProfile(QSharedPointer serv int serverIndex = m_serversModel->getProcessedServerIndex(); DockerContainer container = static_cast(m_containersModel->getProcessedContainerIndex()); + if (ContainerProps::containerService(container) == ServiceType::Other) { + return; + } + QJsonObject containerConfig = m_containersModel->getContainerConfig(container); ServerCredentials serverCredentials = qvariant_cast(m_serversModel->data(serverIndex, ServersModel::Roles::CredentialsRole)); diff --git a/client/ui/controllers/pageController.h b/client/ui/controllers/pageController.h index 1fdb1e81..c9d655ba 100644 --- a/client/ui/controllers/pageController.h +++ b/client/ui/controllers/pageController.h @@ -35,6 +35,7 @@ namespace PageLoader PageServiceSftpSettings, PageServiceTorWebsiteSettings, PageServiceDnsSettings, + PageServiceSocksProxySettings, PageSetupWizardStart, PageSetupWizardCredentials, diff --git a/client/ui/models/clientManagementModel.cpp b/client/ui/models/clientManagementModel.cpp index ea5cb23c..f2117f75 100644 --- a/client/ui/models/clientManagementModel.cpp +++ b/client/ui/models/clientManagementModel.cpp @@ -281,7 +281,8 @@ ErrorCode ClientManagementModel::wgShow(const DockerContainer container, const S } }; - for (int i = 0; i < peerList.size() && i < transferredDataList.size(); ++i) { + for (int i = 0; i < peerList.size() && i < transferredDataList.size() && i < latestHandshakeList.size(); ++i) { + const auto transferredData = getStrValue(transferredDataList[i]).split(","); auto latestHandshake = getStrValue(latestHandshakeList[i]); auto serverBytesReceived = transferredData.front().trimmed(); diff --git a/client/ui/models/protocols_model.cpp b/client/ui/models/protocols_model.cpp index b2838ce3..32447cd4 100644 --- a/client/ui/models/protocols_model.cpp +++ b/client/ui/models/protocols_model.cpp @@ -86,6 +86,7 @@ PageLoader::PageEnum ProtocolsModel::protocolPage(Proto protocol) const case Proto::TorWebSite: return PageLoader::PageEnum::PageServiceTorWebsiteSettings; case Proto::Dns: return PageLoader::PageEnum::PageServiceDnsSettings; case Proto::Sftp: return PageLoader::PageEnum::PageServiceSftpSettings; + case Proto::Socks5Proxy: return PageLoader::PageEnum::PageServiceSocksProxySettings; default: return PageLoader::PageEnum::PageProtocolOpenVpnSettings; } } diff --git a/client/ui/models/servers_model.cpp b/client/ui/models/servers_model.cpp index 480ff3b4..3f167029 100644 --- a/client/ui/models/servers_model.cpp +++ b/client/ui/models/servers_model.cpp @@ -548,6 +548,8 @@ QStringList ServersModel::getAllInstalledServicesName(const int serverIndex) servicesName.append("SFTP"); } else if (container == DockerContainer::TorWebSite) { servicesName.append("TOR"); + } else if (container == DockerContainer::Socks5Proxy) { + servicesName.append("SOCKS5"); } } } @@ -615,15 +617,18 @@ bool ServersModel::isDefaultServerDefaultContainerHasSplitTunneling() { auto server = m_servers.at(m_defaultServerIndex).toObject(); auto defaultContainer = ContainerProps::containerFromString(server.value(config_key::defaultContainer).toString()); - auto containerConfig = server.value(config_key::containers).toArray().at(defaultContainer).toObject(); - auto protocolConfig = containerConfig.value(ContainerProps::containerTypeToString(defaultContainer)).toObject(); - if (defaultContainer == DockerContainer::Awg || defaultContainer == DockerContainer::WireGuard) { - return !(protocolConfig.value(config_key::last_config).toString().contains("AllowedIPs = 0.0.0.0/0, ::/0")); - } else if (defaultContainer == DockerContainer::Cloak || defaultContainer == DockerContainer::OpenVpn - || defaultContainer == DockerContainer::ShadowSocks) { - return !(protocolConfig.value(config_key::last_config).toString().contains("redirect-gateway")); + auto containers = server.value(config_key::containers).toArray(); + for (auto i = 0; i < containers.size(); i++) { + auto container = containers.at(i).toObject(); + if (defaultContainer == DockerContainer::Awg || defaultContainer == DockerContainer::WireGuard) { + auto containerConfig = container.value(ContainerProps::containerTypeToString(defaultContainer)).toObject(); + return !(containerConfig.value(config_key::last_config).toString().contains("AllowedIPs = 0.0.0.0/0, ::/0")); + } else if (defaultContainer == DockerContainer::Cloak || defaultContainer == DockerContainer::OpenVpn + || defaultContainer == DockerContainer::ShadowSocks) { + auto containerConfig = container.value(ContainerProps::containerTypeToString(DockerContainer::OpenVpn)).toObject(); + return !(containerConfig.value(config_key::last_config).toString().contains("redirect-gateway")); + } } - return false; } diff --git a/client/ui/models/services/socks5ProxyConfigModel.cpp b/client/ui/models/services/socks5ProxyConfigModel.cpp new file mode 100644 index 00000000..f68670df --- /dev/null +++ b/client/ui/models/services/socks5ProxyConfigModel.cpp @@ -0,0 +1,80 @@ +#include "socks5ProxyConfigModel.h" + +#include "protocols/protocols_defs.h" + +Socks5ProxyConfigModel::Socks5ProxyConfigModel(QObject *parent) : QAbstractListModel(parent) +{ +} + +int Socks5ProxyConfigModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return 1; +} + +bool Socks5ProxyConfigModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (!index.isValid() || index.row() < 0 || index.row() >= ContainerProps::allContainers().size()) { + return false; + } + + switch (role) { + case Roles::PortRole: m_protocolConfig.insert(config_key::port, value.toString()); break; + case Roles::UserNameRole: m_protocolConfig.insert(config_key::userName, value.toString()); break; + case Roles::PasswordRole: m_protocolConfig.insert(config_key::password, value.toString()); break; + } + + emit dataChanged(index, index, QList { role }); + return true; +} + +QVariant Socks5ProxyConfigModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() < 0 || index.row() >= rowCount()) { + return false; + } + + switch (role) { + case Roles::PortRole: return m_protocolConfig.value(config_key::port).toString(); + case Roles::UserNameRole: + return m_protocolConfig.value(config_key::userName).toString(); + case Roles::PasswordRole: return m_protocolConfig.value(config_key::password).toString(); + } + + return QVariant(); +} + +void Socks5ProxyConfigModel::updateModel(const QJsonObject &config) +{ + beginResetModel(); + m_container = ContainerProps::containerFromString(config.value(config_key::container).toString()); + + m_fullConfig = config; + QJsonObject protocolConfig = config.value(config_key::socks5proxy).toObject(); + + m_protocolConfig.insert(config_key::userName, + protocolConfig.value(config_key::userName).toString()); + + m_protocolConfig.insert(config_key::password, protocolConfig.value(config_key::password).toString()); + + m_protocolConfig.insert(config_key::port, protocolConfig.value(config_key::port).toString()); + + endResetModel(); +} + +QJsonObject Socks5ProxyConfigModel::getConfig() +{ + m_fullConfig.insert(config_key::socks5proxy, m_protocolConfig); + return m_fullConfig; +} + +QHash Socks5ProxyConfigModel::roleNames() const +{ + QHash roles; + + roles[PortRole] = "port"; + roles[UserNameRole] = "username"; + roles[PasswordRole] = "password"; + + return roles; +} diff --git a/client/ui/models/services/socks5ProxyConfigModel.h b/client/ui/models/services/socks5ProxyConfigModel.h new file mode 100644 index 00000000..fc6f2fd4 --- /dev/null +++ b/client/ui/models/services/socks5ProxyConfigModel.h @@ -0,0 +1,40 @@ +#ifndef SOCKS5PROXYCONFIGMODEL_H +#define SOCKS5PROXYCONFIGMODEL_H + +#include +#include + +#include "containers/containers_defs.h" + +class Socks5ProxyConfigModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles { + PortRole = Qt::UserRole + 1, + UserNameRole, + PasswordRole + }; + + explicit Socks5ProxyConfigModel(QObject *parent = nullptr); + + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + bool setData(const QModelIndex &index, const QVariant &value, int role) override; + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + +public slots: + void updateModel(const QJsonObject &config); + QJsonObject getConfig(); + +protected: + QHash roleNames() const override; + +private: + DockerContainer m_container; + QJsonObject m_protocolConfig; + QJsonObject m_fullConfig; +}; + +#endif // SOCKS5PROXYCONFIGMODEL_H diff --git a/client/ui/qml/Pages2/PageServiceSocksProxySettings.qml b/client/ui/qml/Pages2/PageServiceSocksProxySettings.qml new file mode 100644 index 00000000..95343f63 --- /dev/null +++ b/client/ui/qml/Pages2/PageServiceSocksProxySettings.qml @@ -0,0 +1,385 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts + +import SortFilterProxyModel 0.2 + +import PageEnum 1.0 +import ContainerProps 1.0 + +import "./" +import "../Controls2" +import "../Controls2/TextTypes" +import "../Config" +import "../Components" + +PageType { + id: root + + defaultActiveFocusItem: listview + + Connections { + target: InstallController + + function onUpdateContainerFinished() { + PageController.showNotificationMessage(qsTr("Settings updated successfully")) + } + } + + Item { + id: focusItem + KeyNavigation.tab: backButton + } + + ColumnLayout { + id: backButtonLayout + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + anchors.topMargin: 20 + + BackButtonType { + id: backButton + KeyNavigation.tab: listview + } + } + + FlickableType { + id: fl + anchors.top: backButtonLayout.bottom + anchors.bottom: parent.bottom + contentHeight: listview.implicitHeight + + ListView { + id: listview + + width: parent.width + height: listview.contentItem.height + + clip: true + interactive: false + + model: Socks5ProxyConfigModel + + onFocusChanged: { + if (focus) { + listview.currentItem.focusItemId.forceActiveFocus() + } + } + + delegate: Item { + implicitWidth: listview.width + implicitHeight: content.implicitHeight + + property alias focusItemId: hostLabel.rightButton + + ColumnLayout { + id: content + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + + spacing: 0 + + HeaderType { + Layout.fillWidth: true + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + headerText: qsTr("SOCKS5 settings") + } + + LabelWithButtonType { + id: hostLabel + Layout.fillWidth: true + Layout.topMargin: 32 + + parentFlickable: fl + KeyNavigation.tab: portLabel.rightButton + + text: qsTr("Host") + descriptionText: ServersModel.getProcessedServerData("hostName") + + descriptionOnTop: true + + rightImageSource: "qrc:/images/controls/copy.svg" + rightImageColor: "#D7D8DB" + + clickedFunction: function() { + GC.copyToClipBoard(descriptionText) + PageController.showNotificationMessage(qsTr("Copied")) + if (!GC.isMobile()) { + this.rightButton.forceActiveFocus() + } + } + } + + LabelWithButtonType { + id: portLabel + Layout.fillWidth: true + + text: qsTr("Port") + descriptionText: port + + descriptionOnTop: true + + parentFlickable: fl + KeyNavigation.tab: usernameLabel.rightButton + + rightImageSource: "qrc:/images/controls/copy.svg" + rightImageColor: "#D7D8DB" + + clickedFunction: function() { + GC.copyToClipBoard(descriptionText) + PageController.showNotificationMessage(qsTr("Copied")) + if (!GC.isMobile()) { + this.rightButton.forceActiveFocus() + } + } + } + + LabelWithButtonType { + id: usernameLabel + Layout.fillWidth: true + + text: qsTr("User name") + descriptionText: username + + descriptionOnTop: true + + parentFlickable: fl + KeyNavigation.tab: passwordLabel.eyeButton + + rightImageSource: "qrc:/images/controls/copy.svg" + rightImageColor: "#D7D8DB" + + clickedFunction: function() { + GC.copyToClipBoard(descriptionText) + PageController.showNotificationMessage(qsTr("Copied")) + if (!GC.isMobile()) { + this.rightButton.forceActiveFocus() + } + } + } + + LabelWithButtonType { + id: passwordLabel + Layout.fillWidth: true + + text: qsTr("Password") + descriptionText: password + + descriptionOnTop: true + + parentFlickable: fl + eyeButton.KeyNavigation.tab: passwordLabel.rightButton + rightButton.KeyNavigation.tab: changeSettingsButton + + rightImageSource: "qrc:/images/controls/copy.svg" + rightImageColor: "#D7D8DB" + + buttonImageSource: hideDescription ? "qrc:/images/controls/eye.svg" : "qrc:/images/controls/eye-off.svg" + + clickedFunction: function() { + GC.copyToClipBoard(descriptionText) + PageController.showNotificationMessage(qsTr("Copied")) + if (!GC.isMobile()) { + this.rightButton.forceActiveFocus() + } + } + } + + DrawerType2 { + id: changeSettingsDrawer + parent: root + + anchors.fill: parent + expandedHeight: root.height * 0.9 + + onClosed: { + if (!GC.isMobile()) { + focusItem.forceActiveFocus() + } + } + + expandedContent: ColumnLayout { + property string tempPort: port + property string tempUsername: username + property string tempPassword: password + + anchors.top: parent.top + anchors.left: parent.left + anchors.right: parent.right + anchors.topMargin: 32 + anchors.leftMargin: 16 + anchors.rightMargin: 16 + spacing: 0 + + Connections { + target: changeSettingsDrawer + function onOpened() { + if (!GC.isMobile()) { + drawerFocusItem.forceActiveFocus() + } + tempPort = port + tempUsername = username + tempPassword = password + } + function onClosed() { + port = tempPort + username = tempUsername + password = tempPassword + portTextField.textFieldText = port + usernameTextField.textFieldText = username + passwordTextField.textFieldText = password + } + } + + Item { + id: drawerFocusItem + KeyNavigation.tab: portTextField.textField + } + + HeaderType { + Layout.fillWidth: true + + headerText: qsTr("SOCKS5 settings") + } + + TextFieldWithHeaderType { + id: portTextField + + Layout.fillWidth: true + Layout.topMargin: 40 + parentFlickable: fl + + headerText: qsTr("Port") + textFieldText: port + textField.maximumLength: 5 + textField.validator: IntValidator { bottom: 1; top: 65535 } + + textField.onEditingFinished: { + textFieldText = textField.text.replace(/^\s+|\s+$/g, '') + if (textFieldText !== port) { + port = textFieldText + } + } + + KeyNavigation.tab: usernameTextField.textField + } + + TextFieldWithHeaderType { + id: usernameTextField + + Layout.fillWidth: true + Layout.topMargin: 16 + parentFlickable: fl + + headerText: qsTr("Username") + textFieldPlaceholderText: "username" + textFieldText: username + textField.maximumLength: 32 + + textField.onEditingFinished: { + textFieldText = textField.text.replace(/^\s+|\s+$/g, '') + if (textFieldText !== username) { + username = textFieldText + } + } + + KeyNavigation.tab: passwordTextField.textField + } + + TextFieldWithHeaderType { + id: passwordTextField + + property bool hidePassword: true + + Layout.fillWidth: true + Layout.topMargin: 16 + parentFlickable: fl + + headerText: qsTr("Password") + textFieldPlaceholderText: "password" + textFieldText: password + textField.maximumLength: 32 + + textField.echoMode: hidePassword ? TextInput.Password : TextInput.Normal + buttonImageSource: textFieldText !== "" ? (hidePassword ? "qrc:/images/controls/eye.svg" : "qrc:/images/controls/eye-off.svg") + : "" + + clickedFunc: function() { + hidePassword = !hidePassword + } + + textField.onFocusChanged: { + textFieldText = textField.text.replace(/^\s+|\s+$/g, '') + if (textFieldText !== password) { + password = textFieldText + } + } + + KeyNavigation.tab: saveButton + } + + BasicButtonType { + id: saveButton + + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.bottomMargin: 24 + + text: qsTr("Change connection settings") + Keys.onTabPressed: lastItemTabClicked(drawerFocusItem) + + clickedFunc: function() { + forceActiveFocus() + + if (!portTextField.textField.acceptableInput) { + portTextField.errorText = qsTr("The port must be in the range of 1 to 65535") + return + } + if (usernameTextField.textFieldText && passwordTextField.textFieldText === "") { + passwordTextField.errorText = qsTr("Password cannot be empty") + return + } else if (usernameTextField.textFieldText === "" && passwordTextField.textFieldText) { + usernameTextField.errorText = qsTr("Username cannot be empty") + return + } + + PageController.goToPage(PageEnum.PageSetupWizardInstalling) + InstallController.updateContainer(Socks5ProxyConfigModel.getConfig()) + tempPort = portTextField.textFieldText + tempUsername = usernameTextField.textFieldText + tempPassword = passwordTextField.textFieldText + changeSettingsDrawer.close() + } + } + } + } + + BasicButtonType { + id: changeSettingsButton + + Layout.fillWidth: true + Layout.topMargin: 24 + Layout.bottomMargin: 24 + Layout.leftMargin: 16 + Layout.rightMargin: 16 + + text: qsTr("Change connection settings") + Keys.onTabPressed: lastItemTabClicked(focusItem) + + clickedFunc: function() { + forceActiveFocus() + changeSettingsDrawer.open() + } + } + } + } + } + } +} diff --git a/client/ui/qml/Pages2/PageSettingsServerProtocol.qml b/client/ui/qml/Pages2/PageSettingsServerProtocol.qml index 97288733..a0c668be 100644 --- a/client/ui/qml/Pages2/PageSettingsServerProtocol.qml +++ b/client/ui/qml/Pages2/PageSettingsServerProtocol.qml @@ -18,6 +18,8 @@ import "../Components" PageType { id: root + property bool isClearCacheVisible: ServersModel.isProcessedServerHasWriteAccess() && !ContainersModel.isServiceContainer(ContainersModel.getProcessedContainerIndex()) + defaultActiveFocusItem: focusItem Item { @@ -103,6 +105,7 @@ PageType { case ProtocolEnum.Awg: AwgConfigModel.updateModel(ProtocolsModel.getConfig()); break; case ProtocolEnum.Xray: XrayConfigModel.updateModel(ProtocolsModel.getConfig()); break; case ProtocolEnum.Ipsec: Ikev2ConfigModel.updateModel(ProtocolsModel.getConfig()); break; + case ProtocolEnum.Socks5Proxy: Socks5ProxyConfigModel.updateModel(ProtocolsModel.getConfig()); break; } PageController.goToPage(protocolPage); } @@ -124,7 +127,7 @@ PageType { Layout.fillWidth: true - visible: ServersModel.isProcessedServerHasWriteAccess() + visible: root.isClearCacheVisible KeyNavigation.tab: removeButton text: qsTr("Clear %1 profile").arg(ContainersModel.getProcessedContainerName()) @@ -167,7 +170,7 @@ PageType { Layout.leftMargin: 16 Layout.rightMargin: 16 - visible: ServersModel.isProcessedServerHasWriteAccess() + visible: root.isClearCacheVisible } LabelWithButtonType { diff --git a/client/ui/qml/Pages2/PageSetupWizardProtocolSettings.qml b/client/ui/qml/Pages2/PageSetupWizardProtocolSettings.qml index b694dda0..f27873c6 100644 --- a/client/ui/qml/Pages2/PageSetupWizardProtocolSettings.qml +++ b/client/ui/qml/Pages2/PageSetupWizardProtocolSettings.qml @@ -261,6 +261,11 @@ PageType { Keys.onTabPressed: lastItemTabClicked(focusItem) clickedFunc: function() { + if (!port.textField.acceptableInput) { + port.errorText = qsTr("The port must be in the range of 1 to 65535") + return + } + PageController.goToPage(PageEnum.PageSetupWizardInstalling); InstallController.install(dockerContainer, port.textFieldText, transportProtoSelector.currentIndex) }