diff --git a/client/android/build.gradle.kts b/client/android/build.gradle.kts index 05126cf5..1957a526 100644 --- a/client/android/build.gradle.kts +++ b/client/android/build.gradle.kts @@ -85,6 +85,7 @@ dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar")))) implementation(project(":qt")) implementation(project(":utils")) + implementation(project(":protocolApi")) implementation(libs.androidx.core) implementation(libs.androidx.activity) implementation(libs.androidx.security.crypto) diff --git a/client/android/gradle/libs.versions.toml b/client/android/gradle/libs.versions.toml index 6384da26..0094ced0 100644 --- a/client/android/gradle/libs.versions.toml +++ b/client/android/gradle/libs.versions.toml @@ -3,6 +3,7 @@ agp = "8.1.3" kotlin = "1.9.20" androidx-core = "1.12.0" androidx-activity = "1.8.1" +androidx-annotation = "1.7.0" androidx-camera = "1.3.0" androidx-security-crypto = "1.1.0-alpha06" kotlinx-coroutines = "1.7.3" @@ -11,6 +12,7 @@ google-mlkit = "17.2.0" [libraries] androidx-core = { module = "androidx.core:core-ktx", version.ref = "androidx-core" } androidx-activity = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } +androidx-annotation = { module = "androidx.annotation:annotation", version.ref = "androidx-annotation" } androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "androidx-camera" } diff --git a/client/android/protocolApi/build.gradle.kts b/client/android/protocolApi/build.gradle.kts new file mode 100644 index 00000000..9125f7f0 --- /dev/null +++ b/client/android/protocolApi/build.gradle.kts @@ -0,0 +1,17 @@ +plugins { + id(libs.plugins.android.library.get().pluginId) + id(libs.plugins.kotlin.android.get().pluginId) +} + +kotlin { + jvmToolchain(17) +} + +android { + namespace = "org.amnezia.vpn.protocol" +} + +dependencies { + compileOnly(project(":utils")) + implementation(libs.androidx.annotation) +} \ No newline at end of file diff --git a/client/android/protocolApi/src/main/kotlin/Exceptions.kt b/client/android/protocolApi/src/main/kotlin/Exceptions.kt new file mode 100644 index 00000000..40fa965a --- /dev/null +++ b/client/android/protocolApi/src/main/kotlin/Exceptions.kt @@ -0,0 +1,9 @@ +package org.amnezia.vpn.protocol + +sealed class ProtocolException(message: String? = null, cause: Throwable? = null) : Exception(message, cause) + +class LoadLibraryException(message: String? = null, cause: Throwable? = null) : ProtocolException(message, cause) +class VpnNotAuthorizedException(message: String? = null, cause: Throwable? = null) : ProtocolException(message, cause) +class BadConfigException(message: String? = null, cause: Throwable? = null) : ProtocolException(message, cause) + +class VpnStartException(message: String? = null, cause: Throwable? = null) : ProtocolException(message, cause) diff --git a/client/android/protocolApi/src/main/kotlin/Protocol.kt b/client/android/protocolApi/src/main/kotlin/Protocol.kt new file mode 100644 index 00000000..c4d549f7 --- /dev/null +++ b/client/android/protocolApi/src/main/kotlin/Protocol.kt @@ -0,0 +1,115 @@ +package org.amnezia.vpn.protocol + +import android.annotation.SuppressLint +import android.content.Context +import android.net.IpPrefix +import android.net.VpnService +import android.net.VpnService.Builder +import android.os.Build +import android.system.OsConstants +import androidx.annotation.RequiresApi +import java.io.File +import java.io.FileOutputStream +import java.util.zip.ZipFile +import org.amnezia.vpn.Log +import org.json.JSONObject + +private const val TAG = "Protocol" + +const val VPN_SESSION_NAME = "AmneziaVPN" + +abstract class Protocol(protected val context: Context) { + + open lateinit var config: ProtocolConfig + + abstract val statistics: Statistics + + abstract fun initialize() + + abstract fun parseConfig(config: JSONObject) + + protected open fun buildVpnInterface(vpnBuilder: Builder) { + vpnBuilder.setSession(VPN_SESSION_NAME) + vpnBuilder.allowFamily(OsConstants.AF_INET) + vpnBuilder.allowFamily(OsConstants.AF_INET6) + + for (addr in config.addresses) vpnBuilder.addAddress(addr) + for (addr in config.dnsServers) vpnBuilder.addDnsServer(addr) + for (addr in config.routes) vpnBuilder.addRoute(addr) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + for (addr in config.excludedRoutes) vpnBuilder.excludeRoute(addr) + + for (app in config.excludedApplications) vpnBuilder.addDisallowedApplication(app) + + vpnBuilder.setMtu(config.mtu) + vpnBuilder.setBlocking(config.blockingMode) + vpnBuilder.setUnderlyingNetworks(null) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + vpnBuilder.setMetered(false) + } + + abstract fun startVpn(vpnBuilder: Builder, protect: (Int) -> Boolean) + + abstract fun stopVpn() + + companion object { + private fun extractLibrary(context: Context, libraryName: String, destination: File): Boolean { + Log.d(TAG, "Extracting library: $libraryName") + val apks = hashSetOf() + context.applicationInfo.run { + sourceDir?.let { apks += it } + splitSourceDirs?.let { apks += it } + } + for (abi in Build.SUPPORTED_ABIS) { + for (apk in apks) { + ZipFile(File(apk), ZipFile.OPEN_READ).use { zipFile -> + val mappedName = System.mapLibraryName(libraryName) + val libraryZipPath = listOf("lib", abi, mappedName).joinToString(File.separator) + val zipEntry = zipFile.getEntry(libraryZipPath) + zipEntry?.let { + Log.d(TAG, "Extracting apk:/$libraryZipPath to ${destination.absolutePath}") + FileOutputStream(destination).use { outStream -> + zipFile.getInputStream(zipEntry).use { inStream -> + inStream.copyTo(outStream, 32 * 1024) + outStream.fd.sync() + } + } + } + return true + } + } + } + return false + } + + @SuppressLint("UnsafeDynamicallyLoadedCode") + fun loadSharedLibrary(context: Context, libraryName: String) { + Log.d(TAG, "Loading library: $libraryName") + try { + System.loadLibrary(libraryName) + return + } catch (_: UnsatisfiedLinkError) { + Log.d(TAG, "Failed to load library, try to extract it from apk") + } + var tempFile: File? = null + try { + tempFile = File.createTempFile("lib", ".so", context.codeCacheDir) + if (extractLibrary(context, libraryName, tempFile)) { + System.load(tempFile.absolutePath) + return + } + } catch (e: Exception) { + throw LoadLibraryException("Failed to load library apk: $libraryName", e) + } finally { + tempFile?.delete() + } + } + } +} + +private fun VpnService.Builder.addAddress(addr: InetNetwork) = addAddress(addr.address, addr.mask) +private fun VpnService.Builder.addRoute(addr: InetNetwork) = addRoute(addr.address, addr.mask) + +@RequiresApi(Build.VERSION_CODES.TIRAMISU) +private fun VpnService.Builder.excludeRoute(addr: InetNetwork) = excludeRoute(IpPrefix(addr.address, addr.mask)) diff --git a/client/android/protocolApi/src/main/kotlin/ProtocolConfig.kt b/client/android/protocolApi/src/main/kotlin/ProtocolConfig.kt new file mode 100644 index 00000000..f6e5df7d --- /dev/null +++ b/client/android/protocolApi/src/main/kotlin/ProtocolConfig.kt @@ -0,0 +1,121 @@ +package org.amnezia.vpn.protocol + +import android.net.InetAddresses +import android.os.Build +import androidx.annotation.RequiresApi +import java.net.InetAddress + +data class ProtocolConfig( + val addresses: Set, + val dnsServers: Set, + val routes: Set, + val excludedRoutes: Set, + val excludedApplications: Set, + val blockingMode: Boolean, + val mtu: Int +) { + + private constructor(builder: Builder) : this( + builder.addresses, + builder.dnsServers, + builder.routes, + builder.excludedRoutes, + builder.excludedApplications, + builder.blockingMode, + builder.mtu + ) + + class Builder(blockingMode: Boolean) { + internal val addresses: MutableSet = hashSetOf() + internal val dnsServers: MutableSet = hashSetOf() + internal val routes: MutableSet = hashSetOf() + internal val excludedRoutes: MutableSet = hashSetOf() + internal val excludedApplications: MutableSet = hashSetOf() + + internal var blockingMode: Boolean = blockingMode + private set + + internal var mtu: Int = 0 + private set + + fun addAddress(addr: InetNetwork) = apply { this.addresses += addr } + fun addAddresses(addresses: List) = apply { this.addresses += addresses } + + fun addDnsServer(dnsServer: InetAddress) = apply { this.dnsServers += dnsServer } + fun addDnsServers(dnsServers: List) = apply { this.dnsServers += dnsServers } + + fun addRoute(route: InetNetwork) = apply { this.routes += route } + fun addRoutes(routes: List) = apply { this.routes += routes } + + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun excludeRoute(route: InetNetwork) = apply { this.excludedRoutes += route } + @RequiresApi(Build.VERSION_CODES.TIRAMISU) + fun excludeRoutes(routes: List) = apply { this.excludedRoutes += routes } + + fun excludeApplication(application: String) = apply { this.excludedApplications += application } + fun excludeApplications(applications: List) = apply { this.excludedApplications += applications } + + fun setBlockingMode(blockingMode: Boolean) = apply { this.blockingMode = blockingMode } + + fun setMtu(mtu: Int) = apply { this.mtu = mtu } + + private fun validate() { + val errorMessage = StringBuilder() + + with(errorMessage) { + if (addresses.isEmpty()) appendLine("VPN interface network address not specified.") + if (routes.isEmpty()) appendLine("VPN interface route not specified.") + if (mtu == 0) appendLine("MTU not set.") + } + + if (errorMessage.isNotEmpty()) throw BadConfigException(errorMessage.toString()) + } + + fun build(): ProtocolConfig = validate().run { ProtocolConfig(this@Builder) } + } + + companion object { + inline fun build(blockingMode: Boolean, block: Builder.() -> Unit): ProtocolConfig = + Builder(blockingMode).apply(block).build() + } +} + +data class InetNetwork(val address: InetAddress, val mask: Int) { + + override fun toString(): String = "${address.hostAddress}/$mask" + + companion object { + fun parse(data: String): InetNetwork { + val split = data.split("/") + val address = parseInetAddress(split.first()) + val mask = split.last().toInt() + return InetNetwork(address, mask) + } + } +} + +data class InetEndpoint(val address: InetAddress, val port: Int) { + + override fun toString(): String = "${address.hostAddress}:$port" + + companion object { + fun parse(data: String): InetEndpoint { + val split = data.split(":") + val address = parseInetAddress(split.first()) + val port = split.last().toInt() + return InetEndpoint(address, port) + } + } +} + +fun parseInetAddress(address: String): InetAddress = parseNumericAddressCompat(address) + +private val parseNumericAddressCompat: (String) -> InetAddress = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + InetAddresses::parseNumericAddress + } else { + val m = InetAddress::class.java.getMethod("parseNumericAddress", String::class.java) + fun(address: String): InetAddress { + return m.invoke(null, address) as InetAddress + } + } diff --git a/client/android/protocolApi/src/main/kotlin/ProtocolState.kt b/client/android/protocolApi/src/main/kotlin/ProtocolState.kt new file mode 100644 index 00000000..d79949fa --- /dev/null +++ b/client/android/protocolApi/src/main/kotlin/ProtocolState.kt @@ -0,0 +1,9 @@ +package org.amnezia.vpn.protocol + +enum class ProtocolState { + CONNECTED, + CONNECTING, + DISCONNECTED, + DISCONNECTING, + UNKNOWN +} diff --git a/client/android/protocolApi/src/main/kotlin/Statistics.kt b/client/android/protocolApi/src/main/kotlin/Statistics.kt new file mode 100644 index 00000000..d5a72fae --- /dev/null +++ b/client/android/protocolApi/src/main/kotlin/Statistics.kt @@ -0,0 +1,50 @@ +package org.amnezia.vpn.protocol + +import android.os.Bundle + +private const val RX_BYTES_KEY = "rxBytes" +private const val TX_BYTES_KEY = "txBytes" + +@Suppress("DataClassPrivateConstructor") +data class Statistics private constructor( + val rxBytes: Long = 0L, + val txBytes: Long = 0L +) { + + private constructor(builder: Builder) : this(builder.rxBytes, builder.txBytes) + + @Suppress("SuspiciousEqualsCombination") + fun isEmpty(): Boolean = this === EMPTY_STATISTICS || this == EMPTY_STATISTICS + + class Builder { + var rxBytes: Long = 0L + private set + + var txBytes: Long = 0L + private set + + fun setRxBytes(rxBytes: Long) = apply { this.rxBytes = rxBytes } + fun setTxBytes(txBytes: Long) = apply { this.txBytes = txBytes } + + fun build(): Statistics = + if (rxBytes + txBytes == 0L) EMPTY_STATISTICS + else Statistics(this) + } + + companion object { + val EMPTY_STATISTICS: Statistics = Statistics() + + inline fun build(block: Builder.() -> Unit): Statistics = Builder().apply(block).build() + } +} + +fun Bundle.putStatistics(statistics: Statistics) { + putLong(RX_BYTES_KEY, statistics.rxBytes) + putLong(TX_BYTES_KEY, statistics.txBytes) +} + +fun Bundle.getStatistics(): Statistics = + Statistics.build { + setRxBytes(getLong(RX_BYTES_KEY)) + setTxBytes(getLong(TX_BYTES_KEY)) + } diff --git a/client/android/protocolApi/src/main/kotlin/Status.kt b/client/android/protocolApi/src/main/kotlin/Status.kt new file mode 100644 index 00000000..0bda689e --- /dev/null +++ b/client/android/protocolApi/src/main/kotlin/Status.kt @@ -0,0 +1,34 @@ +package org.amnezia.vpn.protocol + +import android.os.Bundle + +private const val IS_CONNECTED_KEY = "isConnected" + +@Suppress("DataClassPrivateConstructor") +data class Status private constructor( + val isConnected: Boolean = false +) { + private constructor(builder: Builder) : this(builder.isConnected) + + class Builder { + var isConnected: Boolean = false + private set + + fun setConnected(isConnected: Boolean) = apply { this.isConnected = isConnected } + + fun build(): Status = Status(this) + } + + companion object { + inline fun build(block: Builder.() -> Unit): Status = Builder().apply(block).build() + } +} + +fun Bundle.putStatus(statistics: Status) { + putBoolean(IS_CONNECTED_KEY, statistics.isConnected) +} + +fun Bundle.getStatus(): Status = + Status.build { + setConnected(getBoolean(IS_CONNECTED_KEY)) + } diff --git a/client/android/settings.gradle.kts b/client/android/settings.gradle.kts index 8cd57b15..86359df2 100644 --- a/client/android/settings.gradle.kts +++ b/client/android/settings.gradle.kts @@ -31,6 +31,7 @@ rootProject.buildFileName = "build.gradle.kts" include(":qt") include(":utils") +include(":protocolApi") // get values from gradle or local properties val androidBuildToolsVersion: String by gradleProperties