diff --git a/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl new file mode 100644 index 000000000..026455be5 --- /dev/null +++ b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksService.aidl @@ -0,0 +1,13 @@ +package com.github.shadowsocks.aidl; + +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback; + +interface IShadowsocksService { + int getState(); + String getProfileName(); + + void registerCallback(in IShadowsocksServiceCallback cb); + void startListeningForBandwidth(in IShadowsocksServiceCallback cb, long timeout); + oneway void stopListeningForBandwidth(in IShadowsocksServiceCallback cb); + oneway void unregisterCallback(in IShadowsocksServiceCallback cb); +} diff --git a/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl new file mode 100644 index 000000000..5d4caa817 --- /dev/null +++ b/client/android/aidl/com/github/shadowsocks/aidl/IShadowsocksServiceCallback.aidl @@ -0,0 +1,11 @@ +package com.github.shadowsocks.aidl; + +import com.github.shadowsocks.aidl.TrafficStats; + +//"oneway" unexpected. xinlake +interface IShadowsocksServiceCallback { + oneway void stateChanged(int state, String profileName, String msg); + oneway void trafficUpdated(long profileId, in TrafficStats stats); + // Traffic data has persisted to database, listener should refetch their data from database + oneway void trafficPersisted(long profileId); +} diff --git a/client/android/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl b/client/android/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl new file mode 100644 index 000000000..8668fa849 --- /dev/null +++ b/client/android/aidl/com/github/shadowsocks/aidl/TrafficStats.aidl @@ -0,0 +1,3 @@ +package com.github.shadowsocks.aidl; + +parcelable TrafficStats; diff --git a/client/android/assets/acl/bypass-lan.acl b/client/android/assets/acl/bypass-lan.acl new file mode 100644 index 000000000..89ca7a726 --- /dev/null +++ b/client/android/assets/acl/bypass-lan.acl @@ -0,0 +1,20 @@ +[proxy_all] + +[bypass_list] +0.0.0.0/8 +10.0.0.0/8 +100.64.0.0/10 +127.0.0.0/8 +169.254.0.0/16 +172.16.0.0/12 +192.0.0.0/24 +192.0.2.0/24 +192.31.196.0/24 +192.52.193.0/24 +192.88.99.0/24 +192.168.0.0/16 +192.175.48.0/24 +198.18.0.0/15 +198.51.100.0/24 +203.0.113.0/24 +224.0.0.0/3 diff --git a/client/android/build.gradle b/client/android/build.gradle index fd2afa467..3796c1456 100644 --- a/client/android/build.gradle +++ b/client/android/build.gradle @@ -1,6 +1,6 @@ buildscript { ext{ - kotlin_version = "1.4.30-M1" + kotlin_version = "1.5.0" // for libwg appcompatVersion = '1.1.0' annotationsVersion = '1.0.1' @@ -43,6 +43,15 @@ dependencies { implementation "androidx.security:security-identity-credential:1.0.0-alpha02" implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:1.2.2" coreLibraryDesugaring "com.android.tools:desugar_jdk_libs:1.0.10" + //ss + implementation "androidx.preference:preference:1.1.0" + implementation "androidx.work:work-runtime-ktx:2.3.4" + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.2.0" + implementation "androidx.room:room-runtime:2.2.5" // runtime + implementation "dnsjava:dnsjava:2.1.9" + implementation "com.google.code.gson:gson:2.8.5" + implementation "org.connectbot.jsocks:jsocks:1.0.0" + annotationProcessor "androidx.room:room-compiler:2.3.0" } android { @@ -88,6 +97,7 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + kotlinOptions.jvmTarget = JavaVersion.VERSION_1_8 lintOptions { abortOnError false diff --git a/client/android/gradlew b/client/android/gradlew old mode 100644 new mode 100755 diff --git a/client/android/res/drawable/ic_navigation_close.xml b/client/android/res/drawable/ic_navigation_close.xml new file mode 100644 index 000000000..e5cc60ced --- /dev/null +++ b/client/android/res/drawable/ic_navigation_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/client/android/res/drawable/ic_service_active.xml b/client/android/res/drawable/ic_service_active.xml new file mode 100644 index 000000000..33062676e --- /dev/null +++ b/client/android/res/drawable/ic_service_active.xml @@ -0,0 +1,11 @@ + + + + diff --git a/client/android/res/drawable/ic_service_busy.xml b/client/android/res/drawable/ic_service_busy.xml new file mode 100644 index 000000000..910b53508 --- /dev/null +++ b/client/android/res/drawable/ic_service_busy.xml @@ -0,0 +1,11 @@ + + + + diff --git a/client/android/res/drawable/ic_service_connected.xml b/client/android/res/drawable/ic_service_connected.xml new file mode 100644 index 000000000..4d6a3272b --- /dev/null +++ b/client/android/res/drawable/ic_service_connected.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/client/android/res/drawable/ic_service_connecting.xml b/client/android/res/drawable/ic_service_connecting.xml new file mode 100644 index 000000000..428938b23 --- /dev/null +++ b/client/android/res/drawable/ic_service_connecting.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/client/android/res/drawable/ic_service_idle.xml b/client/android/res/drawable/ic_service_idle.xml new file mode 100644 index 000000000..6cea1b698 --- /dev/null +++ b/client/android/res/drawable/ic_service_idle.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/client/android/res/drawable/ic_service_stopped.xml b/client/android/res/drawable/ic_service_stopped.xml new file mode 100644 index 000000000..b06e907c3 --- /dev/null +++ b/client/android/res/drawable/ic_service_stopped.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + diff --git a/client/android/res/drawable/ic_service_stopping.xml b/client/android/res/drawable/ic_service_stopping.xml new file mode 100644 index 000000000..49a937a6c --- /dev/null +++ b/client/android/res/drawable/ic_service_stopping.xml @@ -0,0 +1,16 @@ + + + + + + + + diff --git a/client/android/res/drawable/ic_social_share.xml b/client/android/res/drawable/ic_social_share.xml new file mode 100644 index 000000000..fb58d5635 --- /dev/null +++ b/client/android/res/drawable/ic_social_share.xml @@ -0,0 +1,11 @@ + + + diff --git a/client/android/res/values/arrays.xml b/client/android/res/values/arrays.xml new file mode 100644 index 000000000..d4a91258f --- /dev/null +++ b/client/android/res/values/arrays.xml @@ -0,0 +1,210 @@ + + + + @string/add_profile_methods_scan_qr_code + @string/action_import_file + @string/add_profile_methods_manual_settings + + + + RC4-MD5 + AES-128-CFB + AES-192-CFB + AES-256-CFB + AES-128-CTR + AES-192-CTR + AES-256-CTR + BF-CFB + CAMELLIA-128-CFB + CAMELLIA-192-CFB + CAMELLIA-256-CFB + SALSA20 + CHACHA20 + CHACHA20-IETF + AES-128-GCM + AES-192-GCM + AES-256-GCM + CHACHA20-IETF-POLY1305 + XCHACHA20-IETF-POLY1305 + + + + rc4-md5 + aes-128-cfb + aes-192-cfb + aes-256-cfb + aes-128-ctr + aes-192-ctr + aes-256-ctr + bf-cfb + camellia-128-cfb + camellia-192-cfb + camellia-256-cfb + salsa20 + chacha20 + chacha20-ietf + aes-128-gcm + aes-192-gcm + aes-256-gcm + chacha20-ietf-poly1305 + xchacha20-ietf-poly1305 + + + + 1.0.0.0/8 + 2.0.0.0/7 + 4.0.0.0/6 + 8.0.0.0/7 + 11.0.0.0/8 + 12.0.0.0/6 + 16.0.0.0/4 + 32.0.0.0/3 + 64.0.0.0/3 + 96.0.0.0/6 + 100.0.0.0/10 + 100.128.0.0/9 + 101.0.0.0/8 + 102.0.0.0/7 + 104.0.0.0/5 + 112.0.0.0/10 + 112.64.0.0/11 + 112.96.0.0/12 + 112.112.0.0/13 + 112.120.0.0/14 + 112.124.0.0/19 + 112.124.32.0/21 + 112.124.40.0/22 + 112.124.44.0/23 + 112.124.46.0/24 + 112.124.48.0/20 + 112.124.64.0/18 + 112.124.128.0/17 + 112.125.0.0/16 + 112.126.0.0/15 + 112.128.0.0/9 + 113.0.0.0/8 + 114.0.0.0/10 + 114.64.0.0/11 + 114.96.0.0/12 + 114.112.0.0/15 + 114.114.0.0/18 + 114.114.64.0/19 + 114.114.96.0/20 + 114.114.112.0/23 + 114.114.115.0/24 + 114.114.116.0/22 + 114.114.120.0/21 + 114.114.128.0/17 + 114.115.0.0/16 + 114.116.0.0/14 + 114.120.0.0/13 + 114.128.0.0/9 + 115.0.0.0/8 + 116.0.0.0/6 + 120.0.0.0/6 + 124.0.0.0/7 + 126.0.0.0/8 + 128.0.0.0/3 + 160.0.0.0/5 + 168.0.0.0/8 + 169.0.0.0/9 + 169.128.0.0/10 + 169.192.0.0/11 + 169.224.0.0/12 + 169.240.0.0/13 + 169.248.0.0/14 + 169.252.0.0/15 + 169.255.0.0/16 + 170.0.0.0/7 + 172.0.0.0/12 + 172.32.0.0/11 + 172.64.0.0/10 + 172.128.0.0/9 + 173.0.0.0/8 + 174.0.0.0/7 + 176.0.0.0/4 + 192.0.0.8/29 + 192.0.0.16/28 + 192.0.0.32/27 + 192.0.0.64/26 + 192.0.0.128/25 + 192.0.1.0/24 + 192.0.3.0/24 + 192.0.4.0/22 + 192.0.8.0/21 + 192.0.16.0/20 + 192.0.32.0/19 + 192.0.64.0/18 + 192.0.128.0/17 + 192.1.0.0/16 + 192.2.0.0/15 + 192.4.0.0/14 + 192.8.0.0/13 + 192.16.0.0/12 + 192.32.0.0/11 + 192.64.0.0/12 + 192.80.0.0/13 + 192.88.0.0/18 + 192.88.64.0/19 + 192.88.96.0/23 + 192.88.98.0/24 + 192.88.100.0/22 + 192.88.104.0/21 + 192.88.112.0/20 + 192.88.128.0/17 + 192.89.0.0/16 + 192.90.0.0/15 + 192.92.0.0/14 + 192.96.0.0/11 + 192.128.0.0/11 + 192.160.0.0/13 + 192.169.0.0/16 + 192.170.0.0/15 + 192.172.0.0/14 + 192.176.0.0/12 + 192.192.0.0/10 + 193.0.0.0/8 + 194.0.0.0/7 + 196.0.0.0/7 + 198.0.0.0/12 + 198.16.0.0/15 + 198.20.0.0/14 + 198.24.0.0/13 + 198.32.0.0/12 + 198.48.0.0/15 + 198.50.0.0/16 + 198.51.0.0/18 + 198.51.64.0/19 + 198.51.96.0/22 + 198.51.101.0/24 + 198.51.102.0/23 + 198.51.104.0/21 + 198.51.112.0/20 + 198.51.128.0/17 + 198.52.0.0/14 + 198.56.0.0/13 + 198.64.0.0/10 + 198.128.0.0/9 + 199.0.0.0/8 + 200.0.0.0/7 + 202.0.0.0/8 + 203.0.0.0/18 + 203.0.64.0/19 + 203.0.96.0/20 + 203.0.112.0/24 + 203.0.114.0/23 + 203.0.116.0/22 + 203.0.120.0/21 + 203.0.128.0/17 + 203.1.0.0/16 + 203.2.0.0/15 + 203.4.0.0/14 + 203.8.0.0/13 + 203.16.0.0/12 + 203.32.0.0/11 + 203.64.0.0/10 + 203.128.0.0/9 + 204.0.0.0/6 + 208.0.0.0/4 + + diff --git a/client/android/res/values/colors.xml b/client/android/res/values/colors.xml new file mode 100644 index 000000000..422f8369d --- /dev/null +++ b/client/android/res/values/colors.xml @@ -0,0 +1,35 @@ + + + @color/material_primary_100 + @color/material_primary_300 + #7488A1 + + + #388E3C + #00C853 + #CFD8DC + #90A4AE + #607D8B + #546E7A + #455A64 + @color/material_blue_grey_100 + @color/material_blue_grey_300 + @color/material_blue_grey_500 + @color/material_blue_grey_600 + @color/material_blue_grey_700 + @color/material_blue_grey_800 + @color/material_blue_grey_900 + @color/material_green_a700 + + @color/material_primary_500 + @color/material_primary_700 + @color/material_primary_500 + @color/material_primary_800 + @color/material_primary_900 + @color/material_primary_300 + + @color/light_color_primary + @color/light_color_primary_dark + @color/light_color_primary_text + + diff --git a/client/android/res/values/dimen.xml b/client/android/res/values/dimen.xml new file mode 100644 index 000000000..beb517f7b --- /dev/null +++ b/client/android/res/values/dimen.xml @@ -0,0 +1,7 @@ + + + 250dp + 8dp + 88dp + 8dp + diff --git a/client/android/res/values/strings.xml b/client/android/res/values/strings.xml new file mode 100644 index 000000000..19b311346 --- /dev/null +++ b/client/android/res/values/strings.xml @@ -0,0 +1,113 @@ + + + Shadowsocks + Send email + + + Server Settings + Feature Settings + Changes not saved. Do you want to save? + Yes + No + Apply + File Explorer Missing + Browse… + + + Profile Name + Server + Remote Port + Password + Encrypt Method + + + IPv6 Route + Redirect IPv6 traffic to remote + On + Off + Toggling might require ROOT permission + Unsupported kernel version: %s < 3.7.1 + Toggle failed + Send DNS over UDP + Requires UDP forwarding on server side + + + VPN Service + Shadowsocks started. + Invalid server name + Failed to connect the remote server + Stop + Shutting down… + %s + Permission denied to create a VPN service + Failed to start VPN service. You might need to reboot your device. + No valid profile data found. + + + Please select a profile + Proxy/Password should not be empty + Connect + + + Profiles + Settings + About + Shadowsocks %s + Edit + Share + Add Profile + Apply Settings to All Profiles + Export… + Export to file… + Export to Clipboard + Import from Clipboard + Import from file… + Replace from file… + Successfully export! + Failed to export. + Successfully import! + Failed to import. + Fetch location + + + Profile config + Remove + Are you sure you want to remove this profile? + QR code + Add this Shadowsocks Profile? + Scan QR code + Manual Settings + Camera permission is required for scanning QR code. + Undo + + + Connecting… + Connected, tap to check connection + Not connected + + Sent + Received + + + There is no profile currently, would you like to add it now? + SOCKS5 proxy port + Local DNS port + + Toggle + Remote DNS + Sent: \t\t\t\t\t%3$s\t↑\t%1$s\nReceived: \t%4$s\t↓\t%2$s + Check Connectivity + Testing… + Success: HTTPS handshake took %dms + Fail to detect internet connection: %s + Internet Unavailable + Error code: #%d + + %s/s + %1$s↑\t%2$s↓ + + + Removed + %d items removed + + diff --git a/client/android/src/com/github/shadowsocks/Core.kt b/client/android/src/com/github/shadowsocks/Core.kt new file mode 100644 index 000000000..d98df7e4f --- /dev/null +++ b/client/android/src/com/github/shadowsocks/Core.kt @@ -0,0 +1,165 @@ +/******************************************************************************* + * * + * Copyright (C) 2018 by Max Lv * + * Copyright (C) 2018 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks + +import android.app.Application +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.admin.DevicePolicyManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.os.Build +import android.os.UserManager +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import androidx.work.Configuration +import androidx.work.WorkManager +import com.github.shadowsocks.aidl.ShadowsocksConnection +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.net.TcpFastOpen +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.* +import kotlinx.coroutines.DEBUG_PROPERTY_NAME +import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import org.amnezia.vpn.R +import java.io.File +import java.io.IOException +import kotlin.reflect.KClass + +object Core { + const val TAG = "Core" + + lateinit var app: Application + lateinit var configureIntent: (Context) -> PendingIntent + val connectivity by lazy { app.getSystemService()!! } + val packageInfo: PackageInfo by lazy { getPackageInfo(app.packageName) } + val deviceStorage by lazy { if (Build.VERSION.SDK_INT < 24) app else DeviceStorageApp(app) } + val directBootSupported by lazy { + Build.VERSION.SDK_INT >= 24 && app.getSystemService()?.storageEncryptionStatus == DevicePolicyManager.ENCRYPTION_STATUS_ACTIVE_PER_USER + } + + val activeProfileIds + get() = ProfileManager.getProfile(DataStore.profileId).let { + if (it == null) emptyList() else listOfNotNull(it.id) + } + val currentProfile: Pair? + get() { + if (DataStore.directBootAware) DirectBoot.getDeviceProfile()?.apply { return this } + return ProfileManager.expand(ProfileManager.getProfile(DataStore.profileId) + ?: return null) + } + + fun switchProfile(id: Long): Profile { + val result = ProfileManager.getProfile(id) ?: ProfileManager.createProfile() + DataStore.profileId = result.id + return result + } + + fun init(app: Application, configureClass: KClass) { + this.app = app + this.configureIntent = { + PendingIntent.getActivity(it, + 0, + Intent(it, + configureClass.java).setFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT), + 0) + } + + if (Build.VERSION.SDK_INT >= 24) { // migrate old files + deviceStorage.moveDatabaseFrom(app, Key.DB_PUBLIC) + } + + // overhead of debug mode is minimal: https://github.com/Kotlin/kotlinx.coroutines/blob/f528898/docs/debugging.md#debug-mode + System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON) + // Fabric.with(deviceStorage, Crashlytics()) // multiple processes needs manual set-up + // FirebaseApp.initializeApp(deviceStorage) + WorkManager.initialize(deviceStorage, Configuration.Builder().apply { + setExecutor { GlobalScope.launch { it.run() } } + setTaskExecutor { GlobalScope.launch { it.run() } } + }.build()) + + // handle data restored/crash + if (Build.VERSION.SDK_INT >= 24 && DataStore.directBootAware && app.getSystemService()?.isUserUnlocked == true) DirectBoot.flushTrafficStats() + if (DataStore.tcpFastOpen && !TcpFastOpen.sendEnabled) TcpFastOpen.enableTimeout() + if (DataStore.publicStore.getLong(Key.assetUpdateTime, -1) != packageInfo.lastUpdateTime) { + val assetManager = app.assets + try { + for (file in assetManager.list("acl")!!) assetManager.open("acl/$file").use { input -> + File(deviceStorage.noBackupFilesDir, file).outputStream() + .use { output -> input.copyTo(output) } + } + } catch (e: IOException) { + printLog(e) + } + DataStore.publicStore.putLong(Key.assetUpdateTime, packageInfo.lastUpdateTime) + } + updateNotificationChannels() + } + + fun updateNotificationChannels() { + if (Build.VERSION.SDK_INT >= 26) @RequiresApi(26) { + val nm = app.getSystemService()!! + nm.createNotificationChannels(listOf(NotificationChannel("service-vpn", + app.getText(R.string.service_vpn), + if (Build.VERSION.SDK_INT >= 28) NotificationManager.IMPORTANCE_MIN + else NotificationManager.IMPORTANCE_LOW) // #1355 + )) + nm.deleteNotificationChannel("service-nat") // NAT mode is gone for good + } + } + + fun getPackageInfo(packageName: String) = app.packageManager.getPackageInfo(packageName, + if (Build.VERSION.SDK_INT >= 28) PackageManager.GET_SIGNING_CERTIFICATES + else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES)!! + + fun startService() = + ContextCompat.startForegroundService(app, Intent(app, ShadowsocksConnection.serviceClass)) + + fun reloadService() = app.sendBroadcast(Intent(Action.RELOAD)) + fun stopService() = app.sendBroadcast(Intent(Action.CLOSE)) + + fun listenForPackageChanges(onetime: Boolean = true, callback: () -> Unit) = + object : BroadcastReceiver() { + init { + app.registerReceiver(this, IntentFilter().apply { + addAction(Intent.ACTION_PACKAGE_ADDED) + addAction(Intent.ACTION_PACKAGE_REMOVED) + addDataScheme("package") + }) + } + + override fun onReceive(context: Context, intent: Intent) { + if (intent.getBooleanExtra(Intent.EXTRA_REPLACING, false)) return + callback() + if (onetime) app.unregisterReceiver(this) + } + } +} diff --git a/client/android/src/com/github/shadowsocks/LocalVpnService.kt b/client/android/src/com/github/shadowsocks/LocalVpnService.kt new file mode 100644 index 000000000..f5bdf7dd7 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/LocalVpnService.kt @@ -0,0 +1,215 @@ +package com.github.shadowsocks + +import android.app.Service +import android.content.Intent +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.net.Network +import android.net.VpnService +import android.os.Build +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import com.github.shadowsocks.bg.* +import com.github.shadowsocks.net.ConcurrentLocalSocketListener +import com.github.shadowsocks.net.DefaultNetworkListener +import com.github.shadowsocks.net.HostsFile +import com.github.shadowsocks.net.Subnet +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.printLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.Closeable +import java.io.File +import java.io.FileDescriptor +import java.io.IOException +import java.net.URL +import org.amnezia.vpn.R + +class LocalVpnService : VpnService(), LocalDnsService.Interface { + companion object { + private const val VPN_MTU = 1500 + private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1" + private const val PRIVATE_VLAN4_ROUTER = "172.19.0.2" + private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1" + private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2" + + /** + * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466 + */ + private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$") + } + + class CloseableFd(val fd: FileDescriptor) : Closeable { + override fun close() = Os.close(fd) + } + + private inner class ProtectWorker : ConcurrentLocalSocketListener( + "ShadowsocksVpnThread", + File(Core.deviceStorage.noBackupFilesDir, "protect_path") + ) { + override fun acceptInternal(socket: LocalSocket) { + socket.inputStream.read() + val fd = socket.ancillaryFileDescriptors!!.single()!! + CloseableFd(fd).use { + socket.outputStream.write(if (underlyingNetwork.let { network -> + if (network != null && Build.VERSION.SDK_INT >= 23) try { + network.bindSocket(fd) + true + } catch (e: IOException) { + // suppress ENONET (Machine is not on the network) + if ((e.cause as? ErrnoException)?.errno != 64) printLog(e) + false + } else protect(getInt.invoke(fd) as Int) + }) 0 else 1) + } + } + } + + inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException { + override fun getLocalizedMessage() = getString(R.string.reboot_required) + } + + override val data = BaseService.Data(this) + override val tag: String get() = "ShadowsocksVpnService" + override fun createNotification(profileName: String): ServiceNotification = + ServiceNotification(this, profileName, "service-vpn") + + private var conn: ParcelFileDescriptor? = null + private var worker: ProtectWorker? = null + private var active = false + + // metered = false. xinlake + private var underlyingNetwork: Network? = null + set(value) { + field = value + if (active) setUnderlyingNetworks(underlyingNetworks) + } + private val underlyingNetworks + get() = // clearing underlyingNetworks makes Android 9 consider the network to be metered + underlyingNetwork?.let { arrayOf(it) } + + override fun onBind(intent: Intent) = when (intent.action) { + SERVICE_INTERFACE -> super.onBind(intent) + else -> super.onBind(intent) + } + + override fun onRevoke() = stopRunner() + + override fun killProcesses(scope: CoroutineScope) { + super.killProcesses(scope) + active = false + scope.launch { DefaultNetworkListener.stop(this) } + worker?.shutdown(scope) + worker = null + conn?.close() + conn = null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (prepare(this) != null) { +// startActivity(Intent(this, VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } else return super.onStartCommand(intent, flags, startId) + + stopRunner() + return Service.START_NOT_STICKY + } + + override suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it } + override suspend fun resolver(host: String) = + DnsResolverCompat.resolve(DefaultNetworkListener.get(), host) + + override suspend fun openConnection(url: URL) = DefaultNetworkListener.get().openConnection(url) + + override suspend fun startProcesses(hosts: HostsFile) { + worker = ProtectWorker().apply { start() } + super.startProcesses(hosts) + sendFd(startVpn()) + } + + override fun buildAdditionalArguments(cmd: ArrayList): ArrayList { + cmd += "-V" + return cmd + } + + private suspend fun startVpn(): FileDescriptor { + val profile = data.proxy!!.profile + val builder = Builder() + .setConfigureIntent(Core.configureIntent(this)) + .setSession(profile.formattedName) + .setMtu(VPN_MTU) + .addAddress(PRIVATE_VLAN4_CLIENT, 30) + .addDnsServer(PRIVATE_VLAN4_ROUTER) + + if (profile.ipv6) { + builder.addAddress(PRIVATE_VLAN6_CLIENT, 126) + builder.addRoute("::", 0) + } + + // XinLake. bypass lan + resources.getStringArray(R.array.bypass_private_route).forEach { + val subnet = Subnet.fromString(it)!! + builder.addRoute(subnet.address.hostAddress, subnet.prefixSize) + } + builder.addRoute(PRIVATE_VLAN4_ROUTER, 32) + + active = true // possible race condition here? + if (Build.VERSION.SDK_INT >= 22) { + builder.setUnderlyingNetworks(underlyingNetworks) + } + + val conn = builder.establish() ?: throw NullConnectionException() + this.conn = conn + + val cmd = arrayListOf( + File(applicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).absolutePath, + "--netif-ipaddr", PRIVATE_VLAN4_ROUTER, + "--socks-server-addr", "${DataStore.listenAddress}:${DataStore.portProxy}", + "--tunmtu", VPN_MTU.toString(), + "--sock-path", "sock_path", + "--dnsgw", "127.0.0.1:${DataStore.portLocalDns}", + "--loglevel", "warning" + ) + if (profile.ipv6) { + cmd += "--netif-ip6addr" + cmd += PRIVATE_VLAN6_ROUTER + } + cmd += "--enable-udprelay" + data.processes!!.start(cmd, onRestartCallback = { + try { + sendFd(conn.fileDescriptor) + } catch (e: ErrnoException) { + stopRunner(false, e.message) + } + }) + return conn.fileDescriptor + } + + private suspend fun sendFd(fd: FileDescriptor) { + var tries = 0 + val path = File(Core.deviceStorage.noBackupFilesDir, "sock_path").absolutePath + while (true) try { + delay(50L shl tries) + LocalSocket().use { localSocket -> + localSocket.connect( + LocalSocketAddress( + path, + LocalSocketAddress.Namespace.FILESYSTEM + ) + ) + localSocket.setFileDescriptorsForSend(arrayOf(fd)) + localSocket.outputStream.write(42) + } + return + } catch (e: IOException) { + if (tries > 5) throw e + tries += 1 + } + } + + override fun onDestroy() { + super.onDestroy() + data.binder.close() + } +} diff --git a/client/android/src/com/github/shadowsocks/aidl/ShadowsocksConnection.kt b/client/android/src/com/github/shadowsocks/aidl/ShadowsocksConnection.kt new file mode 100644 index 000000000..9c67a51e5 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/aidl/ShadowsocksConnection.kt @@ -0,0 +1,160 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.aidl + +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Handler +import android.os.IBinder +import android.os.RemoteException +import com.github.shadowsocks.LocalVpnService +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.utils.Action + +/** + * This object should be compact as it will not get GC-ed. + */ +class ShadowsocksConnection( + private val handler: Handler = Handler(), + private var listenForDeath: Boolean = false +) : ServiceConnection, + IBinder.DeathRecipient { + companion object { + val serviceClass = LocalVpnService::class.java + } + + interface Callback { + fun stateChanged(state: BaseService.State, profileName: String?, msg: String?) + fun trafficUpdated(profileId: Long, stats: TrafficStats) {} + fun trafficPersisted(profileId: Long) {} + + fun onServiceConnected(service: IShadowsocksService) + + /** + * Different from Android framework, this method will be called even when you call `detachService`. + */ + fun onServiceDisconnected() {} + + fun onBinderDied() {} + } + + private var connectionActive = false + private var callbackRegistered = false + private var callback: Callback? = null + private val serviceCallback = object : IShadowsocksServiceCallback.Stub() { + override fun stateChanged(state: Int, profileName: String?, msg: String?) { + val callback = callback ?: return + handler.post { + callback.stateChanged(BaseService.State.values()[state], profileName, msg) + } + } + + override fun trafficUpdated(profileId: Long, stats: TrafficStats) { + val callback = callback ?: return + handler.post { callback.trafficUpdated(profileId, stats) } + } + + override fun trafficPersisted(profileId: Long) { + val callback = callback ?: return + handler.post { callback.trafficPersisted(profileId) } + } + } + private var binder: IBinder? = null + + var bandwidthTimeout = 0L + set(value) { + try { + if (value > 0) service?.startListeningForBandwidth(serviceCallback, value) + else service?.stopListeningForBandwidth(serviceCallback) + } catch (_: RemoteException) { + } + field = value + } + var service: IShadowsocksService? = null + + override fun onServiceConnected(name: ComponentName?, binder: IBinder) { + this.binder = binder + val service = IShadowsocksService.Stub.asInterface(binder)!! + this.service = service + try { + if (listenForDeath) binder.linkToDeath(this, 0) + check(!callbackRegistered) + service.registerCallback(serviceCallback) + callbackRegistered = true + if (bandwidthTimeout > 0) service.startListeningForBandwidth( + serviceCallback, + bandwidthTimeout + ) + } catch (_: RemoteException) { + } + callback!!.onServiceConnected(service) + } + + override fun onServiceDisconnected(name: ComponentName?) { + unregisterCallback() + callback?.onServiceDisconnected() + service = null + binder = null + } + + override fun binderDied() { + service = null + callbackRegistered = false + callback?.also { handler.post(it::onBinderDied) } + } + + private fun unregisterCallback() { + val service = service + if (service != null && callbackRegistered) try { + service.unregisterCallback(serviceCallback) + } catch (_: RemoteException) { + } + callbackRegistered = false + } + + fun connect(context: Context, callback: Callback) { + if (connectionActive) return + connectionActive = true + check(this.callback == null) + this.callback = callback + val intent = Intent(context, serviceClass).setAction(Action.SERVICE) + context.bindService(intent, this, Context.BIND_AUTO_CREATE) + } + + fun disconnect(context: Context) { + unregisterCallback() + if (connectionActive) try { + context.unbindService(this) + } catch (_: IllegalArgumentException) { + } // ignore + connectionActive = false + if (listenForDeath) binder?.unlinkToDeath(this, 0) + binder = null + try { + service?.stopListeningForBandwidth(serviceCallback) + } catch (_: RemoteException) { + } + service = null + callback = null + } +} diff --git a/client/android/src/com/github/shadowsocks/aidl/TrafficStats.kt b/client/android/src/com/github/shadowsocks/aidl/TrafficStats.kt new file mode 100644 index 000000000..65bcdbf03 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/aidl/TrafficStats.kt @@ -0,0 +1,55 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.aidl + +import android.os.Parcel +import android.os.Parcelable + +data class TrafficStats( + // Bytes per second + var txRate: Long = 0L, var rxRate: Long = 0L, + + // Bytes for the current session + var txTotal: Long = 0L, var rxTotal: Long = 0L) : Parcelable { + operator fun plus(other: TrafficStats) = TrafficStats(txRate + other.txRate, + rxRate + other.rxRate, + txTotal + other.txTotal, + rxTotal + other.rxTotal) + + constructor(parcel: Parcel) : this(parcel.readLong(), + parcel.readLong(), + parcel.readLong(), + parcel.readLong()) + + override fun writeToParcel(parcel: Parcel, flags: Int) { + parcel.writeLong(txRate) + parcel.writeLong(rxRate) + parcel.writeLong(txTotal) + parcel.writeLong(rxTotal) + } + + override fun describeContents() = 0 + + companion object CREATOR : Parcelable.Creator { + override fun createFromParcel(parcel: Parcel) = TrafficStats(parcel) + override fun newArray(size: Int): Array = arrayOfNulls(size) + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/BaseService.kt b/client/android/src/com/github/shadowsocks/bg/BaseService.kt new file mode 100644 index 000000000..3ec5a2917 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/BaseService.kt @@ -0,0 +1,348 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.* +import androidx.core.content.getSystemService +import com.github.shadowsocks.Core +import com.github.shadowsocks.Core.app +import com.github.shadowsocks.aidl.IShadowsocksService +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.aidl.TrafficStats +import com.github.shadowsocks.net.HostsFile +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.* +import kotlinx.coroutines.* +import java.io.File +import java.net.URL +import java.net.UnknownHostException +import java.util.* +import org.amnezia.vpn.R + +/** + * This object uses WeakMap to simulate the effects of multi-inheritance. + */ +object BaseService { + enum class State(val canStop: Boolean = false) { + /** + * Idle state is only used by UI and will never be returned by BaseService. + */ + Idle, + Connecting(true), Connected(true), Stopping, Stopped, + } + + const val CONFIG_FILE = "shadowsocks.conf" + const val CONFIG_FILE_UDP = "shadowsocks-udp.conf" + + interface ExpectedException + class ExpectedExceptionWrapper(e: Exception) : Exception(e.localizedMessage, e), + ExpectedException + + class Data internal constructor(private val service: Interface) { + var state = State.Stopped + var processes: GuardedProcessPool? = null + var proxy: ProxyInstance? = null + // no udpFallback. xinlake + + var notification: ServiceNotification? = null + val closeReceiver = broadcastReceiver { _, intent -> + when (intent.action) { + Intent.ACTION_SHUTDOWN -> service.persistStats() + Action.RELOAD -> service.forceLoad() + else -> service.stopRunner() + } + } + var closeReceiverRegistered = false + + val binder = Binder(this) + var connectingJob: Job? = null + + fun changeState(s: State, msg: String? = null) { + if (state == s && msg == null) return + binder.stateChanged(s, msg) + state = s + } + } + + class Binder(private var data: Data? = null) : IShadowsocksService.Stub(), CoroutineScope, AutoCloseable { + private val callbacks = object : RemoteCallbackList() { + override fun onCallbackDied(callback: IShadowsocksServiceCallback?, cookie: Any?) { + super.onCallbackDied(callback, cookie) + stopListeningForBandwidth(callback ?: return) + } + } + private val bandwidthListeners = mutableMapOf() // the binder is the real identifier + override val coroutineContext = Dispatchers.Main.immediate + Job() + private var looper: Job? = null + + override fun getState(): Int = (data?.state ?: State.Idle).ordinal + override fun getProfileName(): String = data?.proxy?.profile?.name ?: "Idle" + + override fun registerCallback(cb: IShadowsocksServiceCallback) { + callbacks.register(cb) + } + + private fun broadcast(work: (IShadowsocksServiceCallback) -> Unit) { + val count = callbacks.beginBroadcast() + try { + repeat(count) { + try { + work(callbacks.getBroadcastItem(it)) + } catch (_: RemoteException) { + } catch (e: Exception) { + printLog(e) + } + } + } finally { + callbacks.finishBroadcast() + } + } + + private suspend fun loop() { + while (true) { +// delay(bandwidthListeners.values.min() ?: return) + delay(5000) + val proxies = listOfNotNull(data?.proxy) + val stats = proxies.map { Pair(it.profile.id, it.trafficMonitor?.requestUpdate()) } + .filter { it.second != null } + .map { Triple(it.first, it.second!!.first, it.second!!.second) } + if (stats.any { it.third } && data?.state == State.Connected && bandwidthListeners.isNotEmpty()) { + val sum = stats.fold(TrafficStats()) { a, b -> a + b.second } + broadcast { item -> + if (bandwidthListeners.contains(item.asBinder())) { + stats.forEach { (id, stats) -> item.trafficUpdated(id, stats) } + item.trafficUpdated(0, sum) + } + } + } + } + } + + override fun startListeningForBandwidth(cb: IShadowsocksServiceCallback, timeout: Long) { + launch { + if (bandwidthListeners.isEmpty() and (bandwidthListeners.put(cb.asBinder(), timeout) == null)) { + check(looper == null) + looper = launch { loop() } + } + if (data?.state != State.Connected) return@launch + var sum = TrafficStats() + val data = data + val proxy = data?.proxy ?: return@launch + proxy.trafficMonitor?.out.also { stats -> + cb.trafficUpdated(proxy.profile.id, if (stats == null) sum else { + sum += stats + stats + }) + } + + cb.trafficUpdated(0, sum) + } + } + + override fun stopListeningForBandwidth(cb: IShadowsocksServiceCallback) { + launch { + if (bandwidthListeners.remove(cb.asBinder()) != null && bandwidthListeners.isEmpty()) { + looper!!.cancel() + looper = null + } + } + } + + override fun unregisterCallback(cb: IShadowsocksServiceCallback) { + stopListeningForBandwidth(cb) // saves an RPC, and safer + callbacks.unregister(cb) + } + + fun stateChanged(s: State, msg: String?) { + val profileName = profileName + broadcast { it.stateChanged(s.ordinal, profileName, msg) } + } + + fun trafficPersisted(ids: List) { + if (bandwidthListeners.isNotEmpty() && ids.isNotEmpty()) broadcast { item -> + if (bandwidthListeners.contains(item.asBinder())) ids.forEach(item::trafficPersisted) + } + } + + override fun close() { + callbacks.kill() + cancel() + data = null + } + } + + interface Interface { + val data: Data + val tag: String + fun createNotification(profileName: String): ServiceNotification + + fun onBind(intent: Intent): IBinder? = + if (intent.action == Action.SERVICE) data.binder else null + + fun forceLoad() { + val (profile, fallback) = Core.currentProfile + ?: return stopRunner(false, (this as Context).getString(R.string.profile_empty)) + if (profile.host.isEmpty() || profile.password.isEmpty() || fallback != null && (fallback.host.isEmpty() || fallback.password.isEmpty())) { + stopRunner(false, (this as Context).getString(R.string.proxy_empty)) + return + } + val s = data.state + when { + s == State.Stopped -> startRunner() + s.canStop -> stopRunner(true) + // else -> Crashlytics.log(Log.WARN, tag, "Illegal state when invoking use: $s") + } + } + + fun buildAdditionalArguments(cmd: ArrayList): ArrayList = cmd + + suspend fun startProcesses(hosts: HostsFile) { + val configRoot = + (if (Build.VERSION.SDK_INT < 24 || app.getSystemService() + ?.isUserUnlocked != false) app else Core.deviceStorage).noBackupFilesDir + + data.proxy!!.start(this, + File(Core.deviceStorage.noBackupFilesDir, "stat_main"), + File(configRoot, CONFIG_FILE), + "-u") + } + + fun startRunner() { + this as Context + if (Build.VERSION.SDK_INT >= 26) startForegroundService(Intent(this, javaClass)) + else startService(Intent(this, javaClass)) + } + + fun killProcesses(scope: CoroutineScope) { + data.processes?.run { + close(scope) + data.processes = null + } + } + + fun stopRunner(restart: Boolean = false, msg: String? = null) { + if (data.state == State.Stopping) return + // channge the state + data.changeState(State.Stopping) + GlobalScope.launch(Dispatchers.Main.immediate) { + // Core.analytics.logEvent("stop", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag))) + data.connectingJob?.cancelAndJoin() // ensure stop connecting first + this@Interface as Service + // we use a coroutineScope here to allow clean-up in parallel + coroutineScope { + killProcesses(this) + // clean up receivers + val data = data + if (data.closeReceiverRegistered) { + unregisterReceiver(data.closeReceiver) + data.closeReceiverRegistered = false + } + + data.notification?.destroy() + data.notification = null + + val ids = listOfNotNull(data.proxy).map { + it.shutdown(this) + it.profile.id + } + data.proxy = null + data.binder.trafficPersisted(ids) + } + + // change the state + data.changeState(State.Stopped, msg) + + // stop the service if nothing has bound to it + if (restart) startRunner() else { + stopSelf() + } + } + } + + fun persistStats() = + listOfNotNull(data.proxy).forEach { it.trafficMonitor?.persistStats(it.profile.id) } + + suspend fun preInit() {} + suspend fun resolver(host: String) = DnsResolverCompat.resolveOnActiveNetwork(host) + suspend fun openConnection(url: URL) = url.openConnection() + + fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + val data = data + if (data.state != State.Stopped) return Service.START_NOT_STICKY + val profilePair = Core.currentProfile + this as Context + if (profilePair == null) { + // gracefully shutdown: https://stackoverflow.com/q/47337857/2245107 + data.notification = createNotification("") + stopRunner(false, getString(R.string.profile_empty)) + return Service.START_NOT_STICKY + } + val (profile, _) = profilePair + profile.name = profile.formattedName // save name for later queries + val proxy = ProxyInstance(profile) + data.proxy = proxy + + if (!data.closeReceiverRegistered) { + registerReceiver(data.closeReceiver, IntentFilter().apply { + addAction(Action.RELOAD) + addAction(Intent.ACTION_SHUTDOWN) + addAction(Action.CLOSE) + }) + data.closeReceiverRegistered = true + } + + data.notification = createNotification(profile.formattedName) + // Core.analytics.logEvent("start", bundleOf(Pair(FirebaseAnalytics.Param.METHOD, tag))) + + data.changeState(State.Connecting) + data.connectingJob = GlobalScope.launch(Dispatchers.Main) { + try { + Executable.killAll() // clean up old processes + preInit() + val hosts = HostsFile(DataStore.publicStore.getString(Key.hosts) ?: "") + proxy.init(this@Interface, hosts) + + data.processes = GuardedProcessPool { + printLog(it) + stopRunner(false, it.readableMessage) + } + startProcesses(hosts) + // proxy.scheduleUpdate() // XinLake. Bypass-LAN only + data.changeState(State.Connected) + } catch (_: CancellationException) { + // if the job was cancelled, it is canceller's responsibility to call stopRunner + } catch (_: UnknownHostException) { + stopRunner(false, getString(R.string.invalid_server)) + } catch (exc: Throwable) { + if (exc is ExpectedException) exc.printStackTrace() else printLog(exc) + stopRunner(false, "${getString(R.string.service_failed)}: ${exc.readableMessage}") + } finally { + data.connectingJob = null + } + } + return Service.START_NOT_STICKY + } + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/DnsResolverCompat.kt b/client/android/src/com/github/shadowsocks/bg/DnsResolverCompat.kt new file mode 100644 index 000000000..8c93208ed --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/DnsResolverCompat.kt @@ -0,0 +1,99 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.annotation.TargetApi +import android.app.ActivityManager +import android.net.DnsResolver +import android.net.Network +import android.os.Build +import android.os.CancellationSignal +import androidx.core.content.getSystemService +import com.github.shadowsocks.Core +import com.github.shadowsocks.Core.app +import kotlinx.coroutines.* +import java.io.IOException +import java.net.InetAddress +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +sealed class DnsResolverCompat { + companion object : DnsResolverCompat() { + private val instance by lazy { if (Build.VERSION.SDK_INT >= 29) DnsResolverCompat29 else DnsResolverCompat21 } + + override suspend fun resolve(network: Network, host: String) = + instance.resolve(network, host) + + override suspend fun resolveOnActiveNetwork(host: String) = + instance.resolveOnActiveNetwork(host) + } + + abstract suspend fun resolve(network: Network, host: String): Array + abstract suspend fun resolveOnActiveNetwork(host: String): Array + + private object DnsResolverCompat21 : DnsResolverCompat() { + /** + * This dispatcher is used for noncancellable possibly-forever-blocking operations in network IO. + * + * See also: https://issuetracker.google.com/issues/133874590 + */ + private val unboundedIO by lazy { + if (app.getSystemService()!!.isLowRamDevice) Dispatchers.IO + else Executors.newCachedThreadPool().asCoroutineDispatcher() + } + + override suspend fun resolve(network: Network, host: String) = + GlobalScope.async(unboundedIO) { network.getAllByName(host) }.await() + + override suspend fun resolveOnActiveNetwork(host: String) = + GlobalScope.async(unboundedIO) { InetAddress.getAllByName(host) }.await() + } + + @TargetApi(29) + private object DnsResolverCompat29 : DnsResolverCompat(), Executor { + /** + * This executor will run on its caller directly. On Q beta 3 thru 4, this results in calling in main thread. + */ + override fun execute(command: Runnable) = command.run() + + override suspend fun resolve(network: Network, host: String): Array { + return suspendCancellableCoroutine { cont -> + val signal = CancellationSignal() + cont.invokeOnCancellation { signal.cancel() } + // retry should be handled by client instead + DnsResolver.getInstance().query(network, host, DnsResolver.FLAG_NO_RETRY, this, signal, + object : DnsResolver.Callback> { + override fun onAnswer(answer: Collection, rcode: Int) = + cont.resume(answer.toTypedArray()) + + override fun onError(error: DnsResolver.DnsException) = + cont.resumeWithException(IOException(error)) + }) + } + } + + override suspend fun resolveOnActiveNetwork(host: String): Array { + return resolve(Core.connectivity.activeNetwork ?: return emptyArray(), host) + } + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/Executable.kt b/client/android/src/com/github/shadowsocks/bg/Executable.kt new file mode 100644 index 000000000..830f6ccf4 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/Executable.kt @@ -0,0 +1,56 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import android.text.TextUtils +import java.io.File +import java.io.IOException + +object Executable { + // libredsocks.so is not required. xinlake + const val SS_LOCAL = "libss-local.so" + const val TUN2SOCKS = "libtun2socks.so" + + private val EXECUTABLES = setOf(SS_LOCAL, TUN2SOCKS) + + fun killAll() { + for (process in File("/proc").listFiles { _, name -> TextUtils.isDigitsOnly(name) } + ?: return) { + val exe = File(try { + File(process, "cmdline").inputStream().bufferedReader().readText() + } catch (_: IOException) { + continue + }.split(Character.MIN_VALUE, limit = 2).first()) + if (EXECUTABLES.contains(exe.name)) try { + Os.kill(process.name.toInt(), OsConstants.SIGKILL) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) { + e.printStackTrace() + // Crashlytics.log(Log.WARN, "kill", "SIGKILL ${exe.absolutePath} (${process.name}) failed") + // Crashlytics.logException(e) + } + } + } + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/GuardedProcessPool.kt b/client/android/src/com/github/shadowsocks/bg/GuardedProcessPool.kt new file mode 100644 index 000000000..cd58c9c52 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/GuardedProcessPool.kt @@ -0,0 +1,129 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.os.Build +import android.os.SystemClock +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import androidx.annotation.MainThread +import com.github.shadowsocks.Core +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import java.io.File +import java.io.IOException +import java.io.InputStream +import kotlin.concurrent.thread + +class GuardedProcessPool(private val onFatal: suspend (IOException) -> Unit) : CoroutineScope { + companion object { + private const val TAG = "GuardedProcessPool" + private val pid by lazy { + Class.forName("java.lang.ProcessManager\$ProcessImpl").getDeclaredField("pid").apply { isAccessible = true } + } + } + + private inner class Guard(private val cmd: List) { + private lateinit var process: Process + + private fun streamLogger(input: InputStream, logger: (String) -> Unit) = try { + input.bufferedReader().forEachLine(logger) + } catch (_: IOException) { + } // ignore + + fun start() { + process = ProcessBuilder(cmd).directory(Core.deviceStorage.noBackupFilesDir).start() + } + + suspend fun looper(onRestartCallback: (suspend () -> Unit)?) { + var running = true + val cmdName = File(cmd.first()).nameWithoutExtension + val exitChannel = Channel() + try { + while (true) { + thread(name = "stderr-$cmdName") { + streamLogger(process.errorStream) { + // Crashlytics.log(Log.ERROR, cmdName, it) + } + } + thread(name = "stdout-$cmdName") { + streamLogger(process.inputStream) { + // Crashlytics.log(Log.VERBOSE, cmdName, it) + } + // this thread also acts as a daemon thread for waitFor + runBlocking { exitChannel.send(process.waitFor()) } + } + val startTime = SystemClock.elapsedRealtime() + val exitCode = exitChannel.receive() + running = false + when { + SystemClock.elapsedRealtime() - startTime < 1000 -> throw IOException("$cmdName exits too fast (exit code: $exitCode)") + // exitCode == 128 + OsConstants.SIGKILL -> Crashlytics.log(Log.WARN, TAG, "$cmdName was killed") + // else -> Crashlytics.logException(IOException("$cmdName unexpectedly exits with code $exitCode")) + } + // Crashlytics.log(Log.DEBUG, TAG, "restart process: ${Commandline.toString(cmd)} (last exit code: $exitCode)") + start() + running = true + onRestartCallback?.invoke() + } + } catch (e: IOException) { + // Crashlytics.log(Log.WARN, TAG, "error occurred. stop guard: " + Commandline.toString(cmd)) + GlobalScope.launch(Dispatchers.Main) { onFatal(e) } + } finally { + if (running) withContext(NonCancellable) { + // clean-up cannot be cancelled + if (Build.VERSION.SDK_INT < 24) { + try { + Os.kill(pid.get(process) as Int, OsConstants.SIGTERM) + } catch (e: ErrnoException) { + if (e.errno != OsConstants.ESRCH) throw e + } + if (withTimeoutOrNull(500) { exitChannel.receive() } != null) return@withContext + } + process.destroy() // kill the process + if (Build.VERSION.SDK_INT >= 26) { + if (withTimeoutOrNull(1000) { exitChannel.receive() } != null) return@withContext + process.destroyForcibly() // Force to kill the process if it's still alive + } + exitChannel.receive() + } // otherwise process already exited, nothing to be done + } + } + } + + override val coroutineContext = Dispatchers.Main.immediate + Job() + + @MainThread + fun start(cmd: List, onRestartCallback: (suspend () -> Unit)? = null) { + // Crashlytics.log(Log.DEBUG, TAG, "start process: " + Commandline.toString(cmd)) + Guard(cmd).apply { + start() // if start fails, IOException will be thrown directly + launch { looper(onRestartCallback) } + } + } + + @MainThread + fun close(scope: CoroutineScope) { + cancel() + coroutineContext[Job]!!.also { job -> scope.launch { job.join() } } + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/LocalDnsService.kt b/client/android/src/com/github/shadowsocks/bg/LocalDnsService.kt new file mode 100644 index 000000000..a638eba57 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/LocalDnsService.kt @@ -0,0 +1,62 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import com.github.shadowsocks.net.HostsFile +import com.github.shadowsocks.net.LocalDnsServer +import com.github.shadowsocks.net.Socks5Endpoint +import com.github.shadowsocks.preference.DataStore +import kotlinx.coroutines.CoroutineScope +import java.net.InetSocketAddress +import java.net.URI +import java.net.URISyntaxException +import java.util.* + +object LocalDnsService { + private val googleApisTester = + "(^|\\.)googleapis(\\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?){1,2}\$".toRegex() + + private val servers = WeakHashMap() + + interface Interface : BaseService.Interface { + override suspend fun startProcesses(hosts: HostsFile) { + super.startProcesses(hosts) + val profile = data.proxy!!.profile + val dns = try { + URI("dns://${profile.remoteDns}") + } catch (e: URISyntaxException) { + throw BaseService.ExpectedExceptionWrapper(e) + } + LocalDnsServer(this::resolver, + Socks5Endpoint(dns.host, if (dns.port < 0) 53 else dns.port), + DataStore.proxyAddress, + hosts).apply { + tcp = !profile.udpdns + forwardOnly = true + }.also { servers[this] = it }.start(InetSocketAddress(DataStore.listenAddress, DataStore.portLocalDns)) + } + + override fun killProcesses(scope: CoroutineScope) { + servers.remove(this)?.shutdown(scope) + super.killProcesses(scope) + } + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/ProxyInstance.kt b/client/android/src/com/github/shadowsocks/bg/ProxyInstance.kt new file mode 100644 index 000000000..c85d03a18 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/ProxyInstance.kt @@ -0,0 +1,95 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.content.Context +import com.github.shadowsocks.Core +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.net.HostsFile +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.parseNumericAddress +import kotlinx.coroutines.CoroutineScope +import java.io.File +import java.io.IOException +import java.net.UnknownHostException + +/** + * This class sets up environment for ss-local. + */ +class ProxyInstance(val profile: Profile) { + private var configFile: File? = null + var trafficMonitor: TrafficMonitor? = null + + fun getFile(context: Context = Core.deviceStorage) = + File(context.noBackupFilesDir, "bypass-lan.acl") + + suspend fun init(service: BaseService.Interface, hosts: HostsFile) { + // it's hard to resolve DNS on a specific interface so we'll do it here + if (profile.host.parseNumericAddress() == null) { + profile.host = (hosts.resolve(profile.host).firstOrNull() ?: try { + service.resolver(profile.host).firstOrNull() + } catch (_: IOException) { + null + })?.hostAddress ?: throw UnknownHostException() + } + } + + /** + * Sensitive shadowsocks configuration file requires extra protection. It may be stored in encrypted storage or + * device storage, depending on which is currently available. + */ + fun start(service: BaseService.Interface, stat: File, configFile: File, extraFlag: String? = null) { + trafficMonitor = TrafficMonitor(stat) + + this.configFile = configFile + val config = profile.toJson() + configFile.writeText(config.toString()) + + val cmd = service.buildAdditionalArguments(arrayListOf( + File((service as Context).applicationInfo.nativeLibraryDir, Executable.SS_LOCAL).absolutePath, + "-b", DataStore.listenAddress, + "-l", DataStore.portProxy.toString(), + "-t", "600", + "-S", stat.absolutePath, + "-c", configFile.absolutePath)) + if (extraFlag != null) cmd.add(extraFlag) + + cmd += "--acl" + cmd += getFile().absolutePath + + // for UDP profile, it's only going to operate in UDP relay mode-only so this flag has no effect + cmd += "-D" + + if (DataStore.tcpFastOpen) cmd += "--fast-open" + + service.data.processes!!.start(cmd) + } + + fun shutdown(scope: CoroutineScope) { + trafficMonitor?.apply { + thread.shutdown(scope) + persistStats(profile.id) // Make sure update total traffic when stopping the runner + } + trafficMonitor = null + configFile?.delete() // remove old config possibly in device storage + configFile = null + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/ServiceNotification.kt b/client/android/src/com/github/shadowsocks/bg/ServiceNotification.kt new file mode 100644 index 000000000..49d8d106b --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/ServiceNotification.kt @@ -0,0 +1,123 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Build +import android.os.PowerManager +import android.text.format.Formatter +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.core.content.getSystemService +import com.github.shadowsocks.Core +import org.amnezia.vpn.R +import com.github.shadowsocks.aidl.IShadowsocksServiceCallback +import com.github.shadowsocks.aidl.TrafficStats +import com.github.shadowsocks.utils.Action + +/** + * User can customize visibility of notification since Android 8. + * The default visibility: + * + * Android 8.x: always visible due to system limitations + * VPN: always invisible because of VPN notification/icon + * Other: always visible + * + * See also: https://github.com/aosp-mirror/platform_frameworks_base/commit/070d142993403cc2c42eca808ff3fafcee220ac4 + */ +class ServiceNotification(private val service: BaseService.Interface, profileName: String, channel: String, visible: Boolean = false) + : BroadcastReceiver() { + private val callback: IShadowsocksServiceCallback by lazy { + object : IShadowsocksServiceCallback.Stub() { + override fun stateChanged(state: Int, profileName: String?, msg: String?) {} // ignore + override fun trafficUpdated(profileId: Long, stats: TrafficStats) { + if (profileId != 0L) return + builder.apply { + setContentText((service as Context).getString(R.string.traffic, + service.getString(R.string.speed, Formatter.formatFileSize(service, stats.txRate)), + service.getString(R.string.speed, Formatter.formatFileSize(service, stats.rxRate)))) + setSubText(service.getString(R.string.traffic, + Formatter.formatFileSize(service, stats.txTotal), + Formatter.formatFileSize(service, stats.rxTotal))) + } + show() + } + + override fun trafficPersisted(profileId: Long) {} + } + } + private var callbackRegistered = false + + private val builder = NotificationCompat.Builder(service as Context, channel) + .setWhen(0) + .setColor(ContextCompat.getColor(service, R.color.material_primary_500)) + .setTicker(service.getString(R.string.forward_success)) + .setContentTitle(profileName) + .setContentIntent(Core.configureIntent(service)) + .setSmallIcon(R.drawable.ic_service_active) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setPriority(if (visible) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN) + + init { + service as Context + val closeAction = NotificationCompat.Action.Builder( + R.drawable.ic_navigation_close, + service.getString(R.string.stop), + PendingIntent.getBroadcast(service, 0, Intent(Action.CLOSE), 0)).apply { + setShowsUserInterface(false) + }.build() + if (Build.VERSION.SDK_INT < 24) builder.addAction(closeAction) else builder.addInvisibleAction(closeAction) + updateCallback(service.getSystemService()?.isInteractive != false) + service.registerReceiver(this, IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + }) + show() + } + + override fun onReceive(context: Context, intent: Intent) { + if (service.data.state == BaseService.State.Connected) updateCallback(intent.action == Intent.ACTION_SCREEN_ON) + } + + private fun updateCallback(screenOn: Boolean) { + if (screenOn) { + service.data.binder.registerCallback(callback) + service.data.binder.startListeningForBandwidth(callback, 1000) + callbackRegistered = true + } else if (callbackRegistered) { // unregister callback to save battery + service.data.binder.unregisterCallback(callback) + callbackRegistered = false + } + } + + private fun show() = (service as Service).startForeground(1, builder.build()) + + fun destroy() { + (service as Service).unregisterReceiver(this) + updateCallback(false) + service.stopForeground(true) + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/TrafficMonitor.kt b/client/android/src/com/github/shadowsocks/bg/TrafficMonitor.kt new file mode 100644 index 000000000..08f96645f --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/TrafficMonitor.kt @@ -0,0 +1,108 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.net.LocalSocket +import android.os.SystemClock +import com.github.shadowsocks.aidl.TrafficStats +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.net.LocalSocketListener +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.DirectBoot +import java.io.File +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.ByteOrder + +class TrafficMonitor(statFile: File) { + val thread = object : LocalSocketListener("TrafficMonitor-" + statFile.name, statFile) { + private val buffer = ByteArray(16) + private val stat = ByteBuffer.wrap(buffer).order(ByteOrder.LITTLE_ENDIAN) + override fun acceptInternal(socket: LocalSocket) { + if (socket.inputStream.read(buffer) != 16) throw IOException("Unexpected traffic stat length") + val tx = stat.getLong(0) + val rx = stat.getLong(8) + if (current.txTotal != tx) { + current.txTotal = tx + dirty = true + } + if (current.rxTotal != rx) { + current.rxTotal = rx + dirty = true + } + } + }.apply { start() } + + val current = TrafficStats() + var out = TrafficStats() + private var timestampLast = 0L + private var dirty = false + private var persisted: TrafficStats? = null + + fun requestUpdate(): Pair { + val now = SystemClock.elapsedRealtime() + val delta = now - timestampLast + timestampLast = now + var updated = false + if (delta != 0L) { + if (dirty) { + out = current.copy().apply { + txRate = (txTotal - out.txTotal) * 1000 / delta + rxRate = (rxTotal - out.rxTotal) * 1000 / delta + } + dirty = false + updated = true + } else { + if (out.txRate != 0L) { + out.txRate = 0 + updated = true + } + if (out.rxRate != 0L) { + out.rxRate = 0 + updated = true + } + } + } + return Pair(out, updated) + } + + fun persistStats(id: Long) { + val current = current + check(persisted == null || persisted == current) { "Data loss occurred" } + persisted = current + + try { + // profile may have host, etc. modified and thus a re-fetch is necessary (possible race condition) + val profile = ProfileManager.getProfile(id) ?: return + profile.tx += current.txTotal + profile.rx += current.rxTotal + ProfileManager.updateProfile(profile) + } catch (e: IOException) { + if (!DataStore.directBootAware) throw e // we should only reach here because we're in direct boot + val profile = DirectBoot.getDeviceProfile()!!.toList().filterNotNull().single { it.id == id } + profile.tx += current.txTotal + profile.rx += current.rxTotal + profile.dirty = true + DirectBoot.update(profile) + DirectBoot.listenForUnlock() + } + } +} diff --git a/client/android/src/com/github/shadowsocks/bg/VpnService.kt b/client/android/src/com/github/shadowsocks/bg/VpnService.kt new file mode 100644 index 000000000..c777896da --- /dev/null +++ b/client/android/src/com/github/shadowsocks/bg/VpnService.kt @@ -0,0 +1,229 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.bg + +import android.app.Service +import android.content.Intent +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.net.Network +import android.os.Build +import android.os.ParcelFileDescriptor +import android.system.ErrnoException +import android.system.Os +import com.github.shadowsocks.Core +import org.amnezia.vpn.R +import com.github.shadowsocks.net.ConcurrentLocalSocketListener +import com.github.shadowsocks.net.DefaultNetworkListener +import com.github.shadowsocks.net.HostsFile +import com.github.shadowsocks.net.Subnet +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.printLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import java.io.Closeable +import java.io.File +import java.io.FileDescriptor +import java.io.IOException +import java.net.URL + +import java.util.* +import android.net.VpnService as BaseVpnService + +class VpnService : BaseVpnService(), LocalDnsService.Interface { + companion object { + private const val VPN_MTU = 1500 + private const val PRIVATE_VLAN4_CLIENT = "172.19.0.1" + private const val PRIVATE_VLAN4_ROUTER = "172.19.0.2" + private const val PRIVATE_VLAN6_CLIENT = "fdfe:dcba:9876::1" + private const val PRIVATE_VLAN6_ROUTER = "fdfe:dcba:9876::2" + + /** + * https://android.googlesource.com/platform/prebuilts/runtime/+/94fec32/appcompat/hiddenapi-light-greylist.txt#9466 + */ + private val getInt = FileDescriptor::class.java.getDeclaredMethod("getInt$") + } + + class CloseableFd(val fd: FileDescriptor) : Closeable { + override fun close() = Os.close(fd) + } + + private inner class ProtectWorker : ConcurrentLocalSocketListener("ShadowsocksVpnThread", + File(Core.deviceStorage.noBackupFilesDir, "protect_path")) { + override fun acceptInternal(socket: LocalSocket) { + socket.inputStream.read() + val fd = socket.ancillaryFileDescriptors!!.single()!! + CloseableFd(fd).use { + socket.outputStream.write(if (underlyingNetwork.let { network -> + if (network != null && Build.VERSION.SDK_INT >= 23) try { + network.bindSocket(fd) + true + } catch (e: IOException) { + // suppress ENONET (Machine is not on the network) + if ((e.cause as? ErrnoException)?.errno != 64) printLog(e) + false + } else protect(getInt.invoke(fd) as Int) + }) 0 else 1) + } + } + } + + inner class NullConnectionException : NullPointerException(), BaseService.ExpectedException { + override fun getLocalizedMessage() = getString(R.string.reboot_required) + } + + override val data = BaseService.Data(this) + override val tag: String get() = "ShadowsocksVpnService" + override fun createNotification(profileName: String): ServiceNotification = + ServiceNotification(this, profileName, "service-vpn") + + private var conn: ParcelFileDescriptor? = null + private var worker: ProtectWorker? = null + private var active = false + // metered = false. xinlake + private var underlyingNetwork: Network? = null + set(value) { + field = value + if (active) setUnderlyingNetworks(underlyingNetworks) + } + private val underlyingNetworks + get() = // clearing underlyingNetworks makes Android 9 consider the network to be metered + underlyingNetwork?.let { arrayOf(it) } + + override fun onBind(intent: Intent) = when (intent.action) { + SERVICE_INTERFACE -> super.onBind(intent) + else -> super.onBind(intent) + } + + override fun onRevoke() = stopRunner() + + override fun killProcesses(scope: CoroutineScope) { + super.killProcesses(scope) + active = false + scope.launch { DefaultNetworkListener.stop(this) } + worker?.shutdown(scope) + worker = null + conn?.close() + conn = null + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (prepare(this) != null) { +// startActivity(Intent(this, VpnRequestActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)) + } else { + return super.onStartCommand(intent, flags, startId) + } + + stopRunner() + return Service.START_NOT_STICKY + } + + override suspend fun preInit() = DefaultNetworkListener.start(this) { underlyingNetwork = it } + override suspend fun resolver(host: String) = + DnsResolverCompat.resolve(DefaultNetworkListener.get(), host) + + override suspend fun openConnection(url: URL) = DefaultNetworkListener.get().openConnection(url) + + override suspend fun startProcesses(hosts: HostsFile) { + worker = ProtectWorker().apply { start() } + super.startProcesses(hosts) + sendFd(startVpn()) + } + + override fun buildAdditionalArguments(cmd: ArrayList): ArrayList { + cmd += "-V" + return cmd + } + + private suspend fun startVpn(): FileDescriptor { + val profile = data.proxy!!.profile + val builder = Builder() + .setConfigureIntent(Core.configureIntent(this)) + .setSession(profile.formattedName) + .setMtu(VPN_MTU) + .addAddress(PRIVATE_VLAN4_CLIENT, 30) + .addDnsServer(PRIVATE_VLAN4_ROUTER) + + if (profile.ipv6) { + builder.addAddress(PRIVATE_VLAN6_CLIENT, 126) + builder.addRoute("::", 0) + } + + // XinLake. bypass lan + resources.getStringArray(R.array.bypass_private_route).forEach { + val subnet = Subnet.fromString(it)!! + builder.addRoute(subnet.address.hostAddress, subnet.prefixSize) + } + builder.addRoute(PRIVATE_VLAN4_ROUTER, 32) + + active = true // possible race condition here? + if (Build.VERSION.SDK_INT >= 22) { + builder.setUnderlyingNetworks(underlyingNetworks) + } + + val conn = builder.establish() ?: throw NullConnectionException() + this.conn = conn + + val cmd = arrayListOf(File(applicationInfo.nativeLibraryDir, Executable.TUN2SOCKS).absolutePath, + "--netif-ipaddr", PRIVATE_VLAN4_ROUTER, + "--socks-server-addr", "${DataStore.listenAddress}:${DataStore.portProxy}", + "--tunmtu", VPN_MTU.toString(), + "--sock-path", "sock_path", + "--dnsgw", "127.0.0.1:${DataStore.portLocalDns}", + "--loglevel", "warning") + if (profile.ipv6) { + cmd += "--netif-ip6addr" + cmd += PRIVATE_VLAN6_ROUTER + } + cmd += "--enable-udprelay" + data.processes!!.start(cmd, onRestartCallback = { + try { + sendFd(conn.fileDescriptor) + } catch (e: ErrnoException) { + stopRunner(false, e.message) + } + }) + return conn.fileDescriptor + } + + private suspend fun sendFd(fd: FileDescriptor) { + var tries = 0 + val path = File(Core.deviceStorage.noBackupFilesDir, "sock_path").absolutePath + while (true) try { + delay(50L shl tries) + LocalSocket().use { localSocket -> + localSocket.connect(LocalSocketAddress(path, LocalSocketAddress.Namespace.FILESYSTEM)) + localSocket.setFileDescriptorsForSend(arrayOf(fd)) + localSocket.outputStream.write(42) + } + return + } catch (e: IOException) { + if (tries > 5) throw e + tries += 1 + } + } + + override fun onDestroy() { + super.onDestroy() + data.binder.close() + } +} diff --git a/client/android/src/com/github/shadowsocks/database/KeyValuePair.kt b/client/android/src/com/github/shadowsocks/database/KeyValuePair.kt new file mode 100644 index 000000000..1ee916f87 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/database/KeyValuePair.kt @@ -0,0 +1,136 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import androidx.room.* +import java.io.ByteArrayOutputStream +import java.nio.ByteBuffer + +@Entity +class KeyValuePair() { + companion object { + const val TYPE_UNINITIALIZED = 0 + const val TYPE_BOOLEAN = 1 + const val TYPE_FLOAT = 2 + @Deprecated("Use TYPE_LONG.") + const val TYPE_INT = 3 + const val TYPE_LONG = 4 + const val TYPE_STRING = 5 + const val TYPE_STRING_SET = 6 + } + + @androidx.room.Dao + interface Dao { + @Query("SELECT * FROM `KeyValuePair` WHERE `key` = :key") + operator fun get(key: String): KeyValuePair? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun put(value: KeyValuePair): Long + + @Query("DELETE FROM `KeyValuePair` WHERE `key` = :key") + fun delete(key: String): Int + } + + @PrimaryKey + var key: String = "" + var valueType: Int = TYPE_UNINITIALIZED + var value: ByteArray = ByteArray(0) + + val boolean: Boolean? + get() = if (valueType == TYPE_BOOLEAN) ByteBuffer.wrap(value).get() != 0.toByte() else null + val float: Float? + get() = if (valueType == TYPE_FLOAT) ByteBuffer.wrap(value).float else null + @Suppress("DEPRECATION") + @Deprecated("Use long.", ReplaceWith("long")) + val int: Int? + get() = if (valueType == TYPE_INT) ByteBuffer.wrap(value).int else null + val long: Long? + get() = when (valueType) { + @Suppress("DEPRECATION") + TYPE_INT -> ByteBuffer.wrap(value).int.toLong() + TYPE_LONG -> ByteBuffer.wrap(value).long + else -> null + } + val string: String? + get() = if (valueType == TYPE_STRING) String(value) else null + val stringSet: Set? + get() = if (valueType == TYPE_STRING_SET) { + val buffer = ByteBuffer.wrap(value) + val result = HashSet() + while (buffer.hasRemaining()) { + val chArr = ByteArray(buffer.int) + buffer.get(chArr) + result.add(String(chArr)) + } + result + } else null + + @Ignore + constructor(key: String) : this() { + this.key = key + } + + // putting null requires using DataStore + fun put(value: Boolean): KeyValuePair { + valueType = TYPE_BOOLEAN + this.value = ByteBuffer.allocate(1).put((if (value) 1 else 0).toByte()).array() + return this + } + + fun put(value: Float): KeyValuePair { + valueType = TYPE_FLOAT + this.value = ByteBuffer.allocate(4).putFloat(value).array() + return this + } + + @Suppress("DEPRECATION") + @Deprecated("Use long.") + fun put(value: Int): KeyValuePair { + valueType = TYPE_INT + this.value = ByteBuffer.allocate(4).putInt(value).array() + return this + } + + fun put(value: Long): KeyValuePair { + valueType = TYPE_LONG + this.value = ByteBuffer.allocate(8).putLong(value).array() + return this + } + + fun put(value: String): KeyValuePair { + valueType = TYPE_STRING + this.value = value.toByteArray() + return this + } + + fun put(value: Set): KeyValuePair { + valueType = TYPE_STRING_SET + val stream = ByteArrayOutputStream() + val intBuffer = ByteBuffer.allocate(4) + for (v in value) { + intBuffer.rewind() + stream.write(intBuffer.putInt(v.length).array()) + stream.write(v.toByteArray()) + } + this.value = stream.toByteArray() + return this + } +} diff --git a/client/android/src/com/github/shadowsocks/database/PrivateDatabase.kt b/client/android/src/com/github/shadowsocks/database/PrivateDatabase.kt new file mode 100644 index 000000000..0556bbb3e --- /dev/null +++ b/client/android/src/com/github/shadowsocks/database/PrivateDatabase.kt @@ -0,0 +1,63 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import com.github.shadowsocks.Core.app +import com.github.shadowsocks.database.migration.RecreateSchemaMigration +import com.github.shadowsocks.utils.Key +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Database(entities = [Profile::class, KeyValuePair::class], version = 1000) +abstract class PrivateDatabase : RoomDatabase() { + companion object { + private val instance by lazy { + Room.databaseBuilder(app, PrivateDatabase::class.java, Key.DB_PROFILE).apply { + addMigrations(Migration1000) + allowMainThreadQueries() + enableMultiInstanceInvalidation() + fallbackToDestructiveMigration() + setQueryExecutor { GlobalScope.launch { it.run() } } + }.build() + } + + val profileDao get() = instance.profileDao() + val kvPairDao get() = instance.keyValuePairDao() + } + + abstract fun profileDao(): Profile.Dao + abstract fun keyValuePairDao(): KeyValuePair.Dao + + object Migration1000 : RecreateSchemaMigration(999, + 1000, + "Profile", + "(`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT, `host` TEXT NOT NULL, `remotePort` INTEGER NOT NULL, `password` TEXT NOT NULL, `method` TEXT NOT NULL, `remoteDns` TEXT NOT NULL, `udpdns` INTEGER NOT NULL, `ipv6` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL)", + "`id`, `name`, `host`, `remotePort`, `password`, `method`, `remoteDns`, `udpdns`, `ipv6`, `tx`, `rx`, `userOrder`") { + override fun migrate(database: SupportSQLiteDatabase) { + super.migrate(database) + PublicDatabase.Migration3.migrate(database) + } + } +} diff --git a/client/android/src/com/github/shadowsocks/database/Profile.kt b/client/android/src/com/github/shadowsocks/database/Profile.kt new file mode 100644 index 000000000..af457ab89 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/database/Profile.kt @@ -0,0 +1,266 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import android.net.Uri +import android.os.Parcelable +import android.util.Base64 +import android.util.Log +import android.util.LongSparseArray +import androidx.core.net.toUri +import androidx.room.* +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.parsePort +import com.google.gson.JsonArray +import com.google.gson.JsonElement +import com.google.gson.JsonObject +import com.google.gson.JsonPrimitive +import kotlinx.android.parcel.Parcelize +import org.json.JSONObject +import java.io.Serializable +import java.net.URI +import java.net.URISyntaxException +import java.util.* + +@Entity +@Parcelize +data class Profile( + // XinLake. route mode is bypass-lan + @PrimaryKey(autoGenerate = true) + var id: Long = 0, + var name: String? = "", + var host: String = "0.0.0.0", + var remotePort: Int = 0, + var password: String = "0000", + var method: String = "aes-256-cfb", + var remoteDns: String = "8.8.8.8", + var udpdns: Boolean = false, + var ipv6: Boolean = false, + //@TargetApi(28) + var tx: Long = 0, + var rx: Long = 0, + var userOrder: Long = 0, + + @Ignore // not persisted in db, only used by direct boot + var dirty: Boolean = false) : Parcelable, Serializable { + companion object { + private const val TAG = "ShadowParser" + private const val serialVersionUID = 1L + private val pattern = + """(?i)ss://[-a-zA-Z0-9+&@#/%?=.~*'()|!:,;\[\]]*[-a-zA-Z0-9+&@#/%=.~*'()|\[\]]""".toRegex() + private val userInfoPattern = "^(.+?):(.*)$".toRegex() + private val legacyPattern = "^(.+?):(.*)@(.+?):(\\d+?)$".toRegex() + + fun findAllUrls(data: CharSequence?, feature: Profile? = null) = + pattern.findAll(data ?: "").map { + val uri = it.value.toUri() + try { + if (uri.userInfo == null) { + val match = legacyPattern.matchEntire(String(Base64.decode(uri.host, Base64.NO_PADDING))) + if (match != null) { + val profile = Profile() + feature?.copyFeatureSettingsTo(profile) + profile.method = match.groupValues[1].toLowerCase(Locale.ENGLISH) + profile.password = match.groupValues[2] + profile.host = match.groupValues[3] + profile.remotePort = match.groupValues[4].toInt() + profile.name = uri.fragment + profile + } else { + Log.e(TAG, "Unrecognized URI: ${it.value}") + null + } + } else { + val match = userInfoPattern.matchEntire(String(Base64.decode(uri.userInfo, + Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE))) + if (match != null) { + val profile = Profile() + feature?.copyFeatureSettingsTo(profile) + profile.method = match.groupValues[1] + profile.password = match.groupValues[2] + // bug in Android: https://code.google.com/p/android/issues/detail?id=192855 + try { + val javaURI = URI(it.value) + profile.host = javaURI.host ?: "" + if (profile.host.firstOrNull() == '[' && profile.host.lastOrNull() == ']') { + profile.host = profile.host.substring(1, profile.host.length - 1) + } + profile.remotePort = javaURI.port + profile.name = uri.fragment ?: "" + profile + } catch (e: URISyntaxException) { + Log.e(TAG, "Invalid URI: ${it.value}") + null + } + } else { + Log.e(TAG, "Unknown user info: ${it.value}") + null + } + } + } catch (e: IllegalArgumentException) { + Log.e(TAG, "Invalid base64 detected: ${it.value}") + null + } + }.filterNotNull() + + private class JsonParser(private val feature: Profile? = null) : ArrayList() { + private val JsonElement?.optString get() = (this as? JsonPrimitive)?.asString + private val JsonElement?.optBoolean + get() = // asBoolean attempts to cast everything to boolean + (this as? JsonPrimitive)?.run { if (isBoolean) asBoolean else null } + private val JsonElement?.optInt + get() = try { + (this as? JsonPrimitive)?.asInt + } catch (_: NumberFormatException) { + null + } + + private fun tryParse(json: JsonObject, fallback: Boolean = false): Profile? { + val host = json["server"].optString + if (host.isNullOrEmpty()) return null + val remotePort = json["server_port"]?.optInt + if (remotePort == null || remotePort <= 0) return null + val password = json["password"].optString + if (password.isNullOrEmpty()) return null + val method = json["method"].optString + if (method.isNullOrEmpty()) return null + return Profile().also { + it.host = host + it.remotePort = remotePort + it.password = password + it.method = method + }.apply { + feature?.copyFeatureSettingsTo(this) + name = json["remarks"].optString + if (fallback) return@apply + remoteDns = json["remote_dns"].optString ?: remoteDns + ipv6 = json["ipv6"].optBoolean ?: ipv6 + udpdns = json["udpdns"].optBoolean ?: udpdns + } + } + + fun process(json: JsonElement?) { + when (json) { + is JsonObject -> { + val profile = tryParse(json) + if (profile != null) add(profile) else for ((_, value) in json.entrySet()) process(value) + } + is JsonArray -> json.asIterable().forEach(this::process) + // ignore other types + } + } + } + + fun parseJson(json: JsonElement, feature: Profile? = null, create: (Profile) -> Unit) { + JsonParser(feature).run { + process(json) + for (profile in this) create(profile) + } + } + } + + @androidx.room.Dao + interface Dao { + @Query("SELECT * FROM `Profile` WHERE `id` = :id") + operator fun get(id: Long): Profile? + + @Query("SELECT * FROM `Profile` ORDER BY `userOrder`") + fun list(): List + + @Query("SELECT MAX(`userOrder`) + 1 FROM `Profile`") + fun nextOrder(): Long? + + @Query("SELECT 1 FROM `Profile` LIMIT 1") + fun isNotEmpty(): Boolean + + @Insert + fun create(value: Profile): Long + + @Update + fun update(value: Profile): Int + + @Query("DELETE FROM `Profile` WHERE `id` = :id") + fun delete(id: Long): Int + + @Query("DELETE FROM `Profile`") + fun deleteAll(): Int + } + + val formattedAddress get() = (if (host.contains(":")) "[%s]:%d" else "%s:%d").format(host, remotePort) + val formattedName get() = if (name.isNullOrEmpty()) formattedAddress else name!! + + fun copyFeatureSettingsTo(profile: Profile) { + profile.ipv6 = ipv6 + profile.udpdns = udpdns + } + + fun toUri(): Uri { + val auth = Base64.encodeToString("$method:$password".toByteArray(), + Base64.NO_PADDING or Base64.NO_WRAP or Base64.URL_SAFE) + val wrappedHost = if (host.contains(':')) "[$host]" else host + val builder = Uri.Builder().scheme("ss").encodedAuthority("$auth@$wrappedHost:$remotePort") + if (!name.isNullOrEmpty()) builder.fragment(name) + return builder.build() + } + + override fun toString() = toUri().toString() + + fun toJson(profiles: LongSparseArray? = null): JSONObject = JSONObject().apply { + put("server", host) + put("server_port", remotePort) + put("password", password) + put("method", method) + if (profiles == null) return@apply + put("remarks", name) + put("remote_dns", remoteDns) + put("ipv6", ipv6) + put("udpdns", udpdns) + } + + fun serialize() { + DataStore.editingId = id + DataStore.privateStore.putString(Key.name, name) + DataStore.privateStore.putString(Key.host, host) + DataStore.privateStore.putString(Key.remotePort, remotePort.toString()) + DataStore.privateStore.putString(Key.password, password) + DataStore.privateStore.putString(Key.remoteDns, remoteDns) + DataStore.privateStore.putString(Key.method, method) + DataStore.privateStore.putBoolean(Key.udpdns, udpdns) + DataStore.privateStore.putBoolean(Key.ipv6, ipv6) + DataStore.privateStore.remove(Key.dirty) + } + + fun deserialize() { + check(id == 0L || DataStore.editingId == id) + DataStore.editingId = null + // It's assumed that default values are never used, so 0/false/null is always used even if that isn't the case + name = DataStore.privateStore.getString(Key.name) ?: "" + // It's safe to trim the hostname, as we expect no leading or trailing whitespaces here + host = (DataStore.privateStore.getString(Key.host) ?: "").trim() + remotePort = parsePort(DataStore.privateStore.getString(Key.remotePort), 8388, 1) + password = DataStore.privateStore.getString(Key.password) ?: "" + method = DataStore.privateStore.getString(Key.method) ?: "" + remoteDns = DataStore.privateStore.getString(Key.remoteDns) ?: "" + udpdns = DataStore.privateStore.getBoolean(Key.udpdns, false) + ipv6 = DataStore.privateStore.getBoolean(Key.ipv6, false) + } +} diff --git a/client/android/src/com/github/shadowsocks/database/ProfileManager.kt b/client/android/src/com/github/shadowsocks/database/ProfileManager.kt new file mode 100644 index 000000000..87ee260ca --- /dev/null +++ b/client/android/src/com/github/shadowsocks/database/ProfileManager.kt @@ -0,0 +1,140 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import android.database.sqlite.SQLiteCantOpenDatabaseException +import android.util.LongSparseArray +import com.github.shadowsocks.Core +import com.github.shadowsocks.preference.DataStore +import com.github.shadowsocks.utils.DirectBoot +import com.github.shadowsocks.utils.forEachTry +import com.github.shadowsocks.utils.printLog +import com.google.gson.JsonStreamParser +import org.json.JSONArray +import java.io.IOException +import java.io.InputStream +import java.sql.SQLException + +/** + * SQLExceptions are not caught (and therefore will cause crash) for insert/update transactions + * to ensure we are in a consistent state. + */ +object ProfileManager { + interface Listener { + fun onAdd(profile: Profile) + fun onRemove(profileId: Long) + fun onCleared() + } + + var listener: Listener? = null + + @Throws(SQLException::class) + fun createProfile(profile: Profile = Profile()): Profile { + profile.id = 0 + profile.userOrder = PrivateDatabase.profileDao.nextOrder() ?: 0 + profile.id = PrivateDatabase.profileDao.create(profile) + listener?.onAdd(profile) + return profile + } + + fun createProfilesFromJson(jsons: Sequence, replace: Boolean = false) { + val profiles = if (replace) getAllProfiles()?.associateBy { it.formattedAddress } else null + val feature = if (replace) { + profiles?.values?.singleOrNull { it.id == DataStore.profileId } + } else Core.currentProfile?.first + val lazyClear = lazy { clear() } + jsons.asIterable().forEachTry { json -> + Profile.parseJson(JsonStreamParser(json.bufferedReader()).asSequence().single(), feature) { + if (replace) { + lazyClear.value + // if two profiles has the same address, treat them as the same profile and copy stats over + profiles?.get(it.formattedAddress)?.apply { + it.tx = tx + it.rx = rx + } + } + createProfile(it) + } + } + } + + fun serializeToJson(profiles: List? = getAllProfiles()): JSONArray? { + if (profiles == null) return null + val lookup = LongSparseArray(profiles.size).apply { profiles.forEach { put(it.id, it) } } + return JSONArray(profiles.map { it.toJson(lookup) }.toTypedArray()) + } + + /** + * Note: It's caller's responsibility to update DirectBoot profile if necessary. + */ + @Throws(SQLException::class) + fun updateProfile(profile: Profile) = check(PrivateDatabase.profileDao.update(profile) == 1) + + @Throws(IOException::class) + fun getProfile(id: Long): Profile? = try { + PrivateDatabase.profileDao[id] + } catch (ex: SQLiteCantOpenDatabaseException) { + throw IOException(ex) + } catch (ex: SQLException) { + printLog(ex) + null + } + + @Throws(IOException::class) + fun expand(profile: Profile): Pair = Pair(profile, null) + + @Throws(SQLException::class) + fun delProfile(id: Long) { + check(PrivateDatabase.profileDao.delete(id) == 1) + listener?.onRemove(id) + if (id in Core.activeProfileIds && DataStore.directBootAware) DirectBoot.clean() + } + + @Throws(SQLException::class) + fun clear() = PrivateDatabase.profileDao.deleteAll().also { + // listener is not called since this won't be used in mobile submodule + DirectBoot.clean() + listener?.onCleared() + } + + @Throws(IOException::class) + fun ensureNotEmpty() { + val nonEmpty = try { + PrivateDatabase.profileDao.isNotEmpty() + } catch (ex: SQLiteCantOpenDatabaseException) { + throw IOException(ex) + } catch (ex: SQLException) { + printLog(ex) + false + } + if (!nonEmpty) DataStore.profileId = createProfile().id + } + + @Throws(IOException::class) + fun getAllProfiles(): List? = try { + PrivateDatabase.profileDao.list() + } catch (ex: SQLiteCantOpenDatabaseException) { + throw IOException(ex) + } catch (ex: SQLException) { + printLog(ex) + null + } +} diff --git a/client/android/src/com/github/shadowsocks/database/PublicDatabase.kt b/client/android/src/com/github/shadowsocks/database/PublicDatabase.kt new file mode 100644 index 000000000..2a92d95f8 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/database/PublicDatabase.kt @@ -0,0 +1,56 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database + +import androidx.room.Database +import androidx.room.Room +import androidx.room.RoomDatabase +import com.github.shadowsocks.Core +import com.github.shadowsocks.database.migration.RecreateSchemaMigration +import com.github.shadowsocks.utils.Key +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@Database(entities = [KeyValuePair::class], version = 3) +abstract class PublicDatabase : RoomDatabase() { + companion object { + private val instance by lazy { + Room.databaseBuilder(Core.deviceStorage, PublicDatabase::class.java, Key.DB_PUBLIC) + .apply { + addMigrations(Migration3) + allowMainThreadQueries() + enableMultiInstanceInvalidation() + fallbackToDestructiveMigration() + setQueryExecutor { GlobalScope.launch { it.run() } } + }.build() + } + + val kvPairDao get() = instance.keyValuePairDao() + } + + abstract fun keyValuePairDao(): KeyValuePair.Dao + + internal object Migration3 : RecreateSchemaMigration(2, + 3, + "KeyValuePair", + "(`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", + "`key`, `valueType`, `value`") +} diff --git a/client/android/src/com/github/shadowsocks/database/migration/RecreateSchemaMigration.kt b/client/android/src/com/github/shadowsocks/database/migration/RecreateSchemaMigration.kt new file mode 100644 index 000000000..423521563 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/database/migration/RecreateSchemaMigration.kt @@ -0,0 +1,35 @@ +/******************************************************************************* + * * + * Copyright (C) 2018 by Max Lv * + * Copyright (C) 2018 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.database.migration + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +open class RecreateSchemaMigration(oldVersion: Int, newVersion: Int, private val table: String, + private val schema: String, private val keys: String) + : Migration(oldVersion, newVersion) { + override fun migrate(database: SupportSQLiteDatabase) { + database.execSQL("CREATE TABLE `tmp` $schema") + database.execSQL("INSERT INTO `tmp` ($keys) SELECT $keys FROM `$table`") + database.execSQL("DROP TABLE `$table`") + database.execSQL("ALTER TABLE `tmp` RENAME TO `$table`") + } +} diff --git a/client/android/src/com/github/shadowsocks/net/ChannelMonitor.kt b/client/android/src/com/github/shadowsocks/net/ChannelMonitor.kt new file mode 100644 index 000000000..e2153f032 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/ChannelMonitor.kt @@ -0,0 +1,129 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import android.os.Build +import com.github.shadowsocks.utils.printLog +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.sendBlocking +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.channels.* + +class ChannelMonitor : Thread("ChannelMonitor") { + private data class Registration(val channel: SelectableChannel, + val ops: Int, + val listener: (SelectionKey) -> Unit) { + val result = CompletableDeferred() + } + + private val selector = Selector.open() + private val registrationPipe = Pipe.open() + private val pendingRegistrations = Channel(Channel.UNLIMITED) + private val closeChannel = Channel(1) + @Volatile + private var running = true + + private fun registerInternal(channel: SelectableChannel, ops: Int, block: (SelectionKey) -> Unit) = + channel.register(selector, ops, block) + + init { + registrationPipe.source().apply { + configureBlocking(false) + registerInternal(this, SelectionKey.OP_READ) { + val junk = ByteBuffer.allocateDirect(1) + while (read(junk) > 0) { + pendingRegistrations.poll()!!.apply { + try { + result.complete(registerInternal(channel, ops, listener)) + } catch (e: Exception) { + result.completeExceptionally(e) + } + } + junk.clear() + } + } + } + start() + } + + /** + * Prevent NetworkOnMainThreadException because people enable strict mode for no reasons. + */ + private suspend fun WritableByteChannel.writeCompat(src: ByteBuffer) = + if (Build.VERSION.SDK_INT <= 23) withContext(Dispatchers.Default) { write(src) } else write(src) + + suspend fun register(channel: SelectableChannel, ops: Int, block: (SelectionKey) -> Unit): SelectionKey { + val registration = Registration(channel, ops, block) + pendingRegistrations.send(registration) + ByteBuffer.allocateDirect(1).also { junk -> + loop@ while (running) when (registrationPipe.sink().writeCompat(junk)) { + 0 -> kotlinx.coroutines.yield() + 1 -> break@loop + else -> throw IOException("Failed to register in the channel") + } + } + if (!running) throw CancellationException() + return registration.result.await() + } + + suspend fun wait(channel: SelectableChannel, ops: Int) = + CompletableDeferred().run { + register(channel, ops) { + if (it.isValid) try { + it.interestOps(0) // stop listening + } catch (_: CancelledKeyException) { + } + complete(it) + } + await() + } + + override fun run() { + while (running) { + val num = try { + selector.select() + } catch (e: Exception) { + printLog(e) + continue + } + if (num <= 0) continue + val iterator = selector.selectedKeys().iterator() + while (iterator.hasNext()) { + val key = iterator.next() + iterator.remove() + (key.attachment() as (SelectionKey) -> Unit)(key) + } + } + closeChannel.sendBlocking(Unit) + } + + fun close(scope: CoroutineScope) { + running = false + selector.wakeup() + scope.launch { + closeChannel.receive() + selector.keys().forEach { it.channel().close() } + selector.close() + } + } +} diff --git a/client/android/src/com/github/shadowsocks/net/ConcurrentLocalSocketListener.kt b/client/android/src/com/github/shadowsocks/net/ConcurrentLocalSocketListener.kt new file mode 100644 index 000000000..b00d9bdc2 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/ConcurrentLocalSocketListener.kt @@ -0,0 +1,43 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import android.net.LocalSocket +import com.github.shadowsocks.utils.printLog +import kotlinx.coroutines.* +import java.io.File + +abstract class ConcurrentLocalSocketListener(name: String, socketFile: File) : + LocalSocketListener(name, socketFile), CoroutineScope { + override val coroutineContext = + Dispatchers.IO + SupervisorJob() + CoroutineExceptionHandler { _, t -> printLog(t) } + + override fun accept(socket: LocalSocket) { + launch { super.accept(socket) } + } + + override fun shutdown(scope: CoroutineScope) { + running = false + cancel() + super.shutdown(scope) + coroutineContext[Job]!!.also { job -> scope.launch { job.join() } } + } +} diff --git a/client/android/src/com/github/shadowsocks/net/DefaultNetworkListener.kt b/client/android/src/com/github/shadowsocks/net/DefaultNetworkListener.kt new file mode 100644 index 000000000..9ce13513d --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/DefaultNetworkListener.kt @@ -0,0 +1,145 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import android.annotation.TargetApi +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import com.github.shadowsocks.Core +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.channels.actor +import kotlinx.coroutines.runBlocking +import java.net.UnknownHostException + +object DefaultNetworkListener { + private sealed class NetworkMessage { + class Start(val key: Any, val listener: (Network?) -> Unit) : NetworkMessage() + class Get : NetworkMessage() { + val response = CompletableDeferred() + } + + class Stop(val key: Any) : NetworkMessage() + + class Put(val network: Network) : NetworkMessage() + class Update(val network: Network) : NetworkMessage() + class Lost(val network: Network) : NetworkMessage() + } + + private val networkActor = GlobalScope.actor(Dispatchers.Unconfined) { + val listeners = mutableMapOf Unit>() + var network: Network? = null + val pendingRequests = arrayListOf() + for (message in channel) when (message) { + is NetworkMessage.Start -> { + if (listeners.isEmpty()) register() + listeners[message.key] = message.listener + if (network != null) message.listener(network) + } + is NetworkMessage.Get -> { + check(listeners.isNotEmpty()) { "Getting network without any listeners is not supported" } + if (network == null) pendingRequests += message else message.response.complete(network) + } + is NetworkMessage.Stop -> if (listeners.isNotEmpty() && // was not empty + listeners.remove(message.key) != null && listeners.isEmpty()) { + network = null + unregister() + } + + is NetworkMessage.Put -> { + network = message.network + pendingRequests.forEach { it.response.complete(message.network) } + pendingRequests.clear() + listeners.values.forEach { it(network) } + } + is NetworkMessage.Update -> if (network == message.network) listeners.values.forEach { + it(network) + } + is NetworkMessage.Lost -> if (network == message.network) { + network = null + listeners.values.forEach { it(null) } + } + } + } + + suspend fun start(key: Any, listener: (Network?) -> Unit) = + networkActor.send(NetworkMessage.Start(key, listener)) + + suspend fun get() = if (fallback) @TargetApi(23) { + Core.connectivity.activeNetwork + ?: throw UnknownHostException() // failed to listen, return current if available + } else NetworkMessage.Get().run { + networkActor.send(this) + response.await() + } + + suspend fun stop(key: Any) = networkActor.send(NetworkMessage.Stop(key)) + + // NB: this runs in ConnectivityThread, and this behavior cannot be changed until API 26 +// private object Callback : ConnectivityManager.NetworkCallback() { +// override fun onAvailable(network: Network) = +// runBlocking { networkActor.send(NetworkMessage.Put(network)) } +// +// override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities?) { +// // it's a good idea to refresh capabilities +// runBlocking { networkActor.send(NetworkMessage.Update(network)) } +// } +// +// override fun onLost(network: Network) = +// runBlocking { networkActor.send(NetworkMessage.Lost(network)) } +// } + + private var fallback = false + private val request = NetworkRequest.Builder().apply { + addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED) + }.build() + + /** + * Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1: + * https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e + * + * This makes doing a requestNetwork with REQUEST necessary so that we don't get ALL possible networks that + * satisfies default network capabilities but only THE default network. Unfortunately, we need to have + * android.permission.CHANGE_NETWORK_STATE to be able to call requestNetwork. + * + * Source: https://android.googlesource.com/platform/frameworks/base/+/2df4c7d/services/core/java/com/android/server/ConnectivityService.java#887 + */ + private fun register() { +// if (Build.VERSION.SDK_INT in 24..27) @TargetApi(24) { +// Core.connectivity.registerDefaultNetworkCallback(Callback) +// } else try { +// fallback = false +// // we want REQUEST here instead of LISTEN +// Core.connectivity.requestNetwork(request, Callback) +// } catch (e: SecurityException) { +// // known bug: https://stackoverflow.com/a/33509180/2245107 +// // if (Build.VERSION.SDK_INT != 23) Crashlytics.logException(e) +// fallback = true +// } + } + + private fun unregister() {}//= Core.connectivity.unregisterNetworkCallback(Callback) +} diff --git a/client/android/src/com/github/shadowsocks/net/HostsFile.kt b/client/android/src/com/github/shadowsocks/net/HostsFile.kt new file mode 100644 index 000000000..9bffd7753 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/HostsFile.kt @@ -0,0 +1,40 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import com.github.shadowsocks.utils.computeIfAbsentCompat +import com.github.shadowsocks.utils.parseNumericAddress +import java.net.InetAddress + +class HostsFile(input: String = "") { + private val map = mutableMapOf>() + + init { + for (line in input.lineSequence()) { + val entries = line.substringBefore('#').splitToSequence(' ', '\t').filter { it.isNotEmpty() } + val address = entries.firstOrNull()?.parseNumericAddress() ?: continue + for (hostname in entries.drop(1)) map.computeIfAbsentCompat(hostname) { LinkedHashSet(1) }.add(address) + } + } + + val configuredHostnames get() = map.size + fun resolve(hostname: String) = map[hostname]?.shuffled() ?: emptyList() +} diff --git a/client/android/src/com/github/shadowsocks/net/HttpsTest.kt b/client/android/src/com/github/shadowsocks/net/HttpsTest.kt new file mode 100644 index 000000000..9c8e73ae5 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/HttpsTest.kt @@ -0,0 +1,121 @@ +/******************************************************************************* + * * + * Copyright (C) 2018 by Max Lv * + * Copyright (C) 2018 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import android.os.Build +import android.os.SystemClock +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import com.github.shadowsocks.Core.app +import org.amnezia.vpn.R +import com.github.shadowsocks.utils.useCancellable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLConnection + +/** + * Based on: https://android.googlesource.com/platform/frameworks/base/+/b19a838/services/core/java/com/android/server/connectivity/NetworkMonitor.java#1071 + */ +class HttpsTest : ViewModel() { + sealed class Status { + protected abstract val status: CharSequence + open fun retrieve(setStatus: (CharSequence) -> Unit, errorCallback: (String) -> Unit) = + setStatus(status) + + object Idle : Status() { + override val status get() = app.getText(R.string.vpn_connected) + } + + object Testing : Status() { + override val status get() = app.getText(R.string.connection_test_testing) + } + + class Success(private val elapsed: Long) : Status() { + override val status get() = app.getString(R.string.connection_test_available, elapsed) + } + + sealed class Error : Status() { + override val status get() = app.getText(R.string.connection_test_fail) + protected abstract val error: String + private var shown = false + override fun retrieve(setStatus: (CharSequence) -> Unit, errorCallback: (String) -> Unit) { + super.retrieve(setStatus, errorCallback) + if (shown) return + shown = true + errorCallback(error) + } + + class UnexpectedResponseCode(private val code: Int) : Error() { + override val error get() = app.getString(R.string.connection_test_error_status_code, code) + } + + class IOFailure(private val e: IOException) : Error() { + override val error get() = app.getString(R.string.connection_test_error, e.message) + } + } + } + + private var running: Job? = null + val status = MutableLiveData().apply { value = Status.Idle } + + fun testConnection() { + cancelTest() + status.value = Status.Testing + val url = URL("https", "www.google.com", "/generate_204") + val conn = (url.openConnection()) as HttpURLConnection + conn.setRequestProperty("Connection", "close") + conn.instanceFollowRedirects = false + conn.useCaches = false + running = GlobalScope.launch(Dispatchers.Main.immediate) { + status.value = conn.useCancellable { + try { + val start = SystemClock.elapsedRealtime() + val code = responseCode + val elapsed = SystemClock.elapsedRealtime() - start + if (code == 204 || code == 200 && responseLength == 0L) Status.Success(elapsed) + else Status.Error.UnexpectedResponseCode(code) + } catch (e: IOException) { + Status.Error.IOFailure(e) + } finally { + disconnect() + } + } + } + } + + private fun cancelTest() { + running?.cancel() + running = null + } + + fun invalidate() { + cancelTest() + status.value = Status.Idle + } + + private val URLConnection.responseLength: Long + get() = if (Build.VERSION.SDK_INT >= 24) contentLengthLong else contentLength.toLong() +} diff --git a/client/android/src/com/github/shadowsocks/net/LocalDnsServer.kt b/client/android/src/com/github/shadowsocks/net/LocalDnsServer.kt new file mode 100644 index 000000000..99b5a8496 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/LocalDnsServer.kt @@ -0,0 +1,194 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.utils.printLog +import kotlinx.coroutines.* +import org.xbill.DNS.* +import java.io.IOException +import java.net.* +import java.nio.ByteBuffer +import java.nio.channels.DatagramChannel +import java.nio.channels.SelectionKey +import java.nio.channels.SocketChannel + +/** + * A simple DNS conditional forwarder. + * + * No cache is provided as localResolver may change from time to time. We expect DNS clients to do cache themselves. + * + * Based on: + * https://github.com/bitcoinj/httpseed/blob/809dd7ad9280f4bc98a356c1ffb3d627bf6c7ec5/src/main/kotlin/dns.kt + * https://github.com/shadowsocks/overture/tree/874f22613c334a3b78e40155a55479b7b69fee04 + */ +class LocalDnsServer(private val localResolver: suspend (String) -> Array, + private val remoteDns: Socks5Endpoint, + private val proxy: SocketAddress, + private val hosts: HostsFile) : CoroutineScope { + /** + * Forward all requests to remote and ignore localResolver. + */ + var forwardOnly = false + /** + * Forward UDP queries to TCP. + */ + var tcp = true + var remoteDomainMatcher: Regex? = null + var localIpMatcher: List = emptyList() + + companion object { + private const val TAG = "LocalDnsServer" + private const val TIMEOUT = 10_000L + /** + * TTL returned from localResolver is set to 120. Android API does not provide TTL, + * so we suppose Android apps should not care about TTL either. + */ + private const val TTL = 120L + private const val UDP_PACKET_SIZE = 512 + + private fun prepareDnsResponse(request: Message) = Message(request.header.id).apply { + header.setFlag(Flags.QR.toInt()) // this is a response + if (request.header.getFlag(Flags.RD.toInt())) header.setFlag(Flags.RD.toInt()) + request.question?.also { addRecord(it, Section.QUESTION) } + } + + private fun cookDnsResponse(request: Message, results: Iterable) = + ByteBuffer.wrap(prepareDnsResponse(request).apply { + header.setFlag(Flags.RA.toInt()) // recursion available + for (address in results) addRecord(when (address) { + is Inet4Address -> ARecord(question.name, DClass.IN, TTL, address) + is Inet6Address -> AAAARecord(question.name, DClass.IN, TTL, address) + else -> error("Unsupported address $address") + }, Section.ANSWER) + }.toWire()) + } + + private val monitor = ChannelMonitor() + + override val coroutineContext = + SupervisorJob() + CoroutineExceptionHandler { _, t -> printLog(t) } + + suspend fun start(listen: SocketAddress) = DatagramChannel.open().run { + configureBlocking(false) + try { + socket().bind(listen) + } catch (e: BindException) { + throw BaseService.ExpectedExceptionWrapper(e) + } + monitor.register(this, SelectionKey.OP_READ) { handlePacket(this) } + } + + private fun handlePacket(channel: DatagramChannel) { + val buffer = ByteBuffer.allocateDirect(UDP_PACKET_SIZE) + val source = channel.receive(buffer)!! + buffer.flip() + launch { + val reply = resolve(buffer) + while (channel.send(reply, source) <= 0) monitor.wait(channel, SelectionKey.OP_WRITE) + } + } + + private suspend fun resolve(packet: ByteBuffer): ByteBuffer { + val request = try { + Message(packet) + } catch (e: IOException) { // we cannot parse the message, do not attempt to handle it at all + // Crashlytics.log(Log.WARN, TAG, e.message) + return forward(packet) + } + return supervisorScope { + val remote = async { withTimeout(TIMEOUT) { forward(packet) } } + try { + if (request.header.opcode != Opcode.QUERY) return@supervisorScope remote.await() + val question = request.question + if (question?.type != Type.A) return@supervisorScope remote.await() + val host = question.name.toString(true) + val hostsResults = hosts.resolve(host) + if (hostsResults.isNotEmpty()) { + remote.cancel() + return@supervisorScope cookDnsResponse(request, hostsResults) + } + if (forwardOnly) return@supervisorScope remote.await() + if (remoteDomainMatcher?.containsMatchIn(host) == true) return@supervisorScope remote.await() + val localResults = try { + withTimeout(TIMEOUT) { localResolver(host) } + } catch (_: TimeoutCancellationException) { + // Crashlytics.log(Log.WARN, TAG, "Local resolving timed out, falling back to remote resolving") + return@supervisorScope remote.await() + } catch (_: UnknownHostException) { + return@supervisorScope remote.await() + } + if (localResults.isEmpty()) return@supervisorScope remote.await() + if (localIpMatcher.isEmpty() || localIpMatcher.any { subnet -> localResults.any(subnet::matches) }) { + remote.cancel() + cookDnsResponse(request, localResults.asIterable()) + } else remote.await() + } catch (e: Exception) { + remote.cancel() + when (e) { + // is TimeoutCancellationException -> Crashlytics.log(Log.WARN, TAG, "Remote resolving timed out") + is CancellationException -> { + } // ignore + // is IOException -> Crashlytics.log(Log.WARN, TAG, e.message) + else -> printLog(e) + } + ByteBuffer.wrap(prepareDnsResponse(request).apply { + header.rcode = Rcode.SERVFAIL + }.toWire()) + } + } + } + + private suspend fun forward(packet: ByteBuffer): ByteBuffer { + packet.position(0) // the packet might have been parsed, reset to beginning + return if (tcp) SocketChannel.open().use { channel -> + channel.configureBlocking(false) + channel.connect(proxy) + val wrapped = remoteDns.tcpWrap(packet) + while (!channel.finishConnect()) monitor.wait(channel, SelectionKey.OP_CONNECT) + while (channel.write(wrapped) >= 0 && wrapped.hasRemaining()) monitor.wait(channel, SelectionKey.OP_WRITE) + val result = remoteDns.tcpReceiveBuffer(UDP_PACKET_SIZE) + remoteDns.tcpUnwrap(result, channel::read) { + monitor.wait(channel, SelectionKey.OP_READ) + } + result + } else DatagramChannel.open().use { channel -> + channel.configureBlocking(false) + monitor.wait(channel, SelectionKey.OP_WRITE) + check(channel.send(remoteDns.udpWrap(packet), proxy) > 0) + val result = remoteDns.udpReceiveBuffer(UDP_PACKET_SIZE) + while (isActive) { + monitor.wait(channel, SelectionKey.OP_READ) + if (channel.receive(result) == proxy) break + result.clear() + } + result.flip() + remoteDns.udpUnwrap(result) + result + } + } + + fun shutdown(scope: CoroutineScope) { + cancel() + monitor.close(scope) + coroutineContext[Job]!!.also { job -> scope.launch { job.join() } } + } +} diff --git a/client/android/src/com/github/shadowsocks/net/LocalSocketListener.kt b/client/android/src/com/github/shadowsocks/net/LocalSocketListener.kt new file mode 100644 index 000000000..8ac45f269 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/LocalSocketListener.kt @@ -0,0 +1,80 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import android.net.LocalServerSocket +import android.net.LocalSocket +import android.net.LocalSocketAddress +import android.system.ErrnoException +import android.system.Os +import android.system.OsConstants +import com.github.shadowsocks.utils.printLog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.sendBlocking +import kotlinx.coroutines.launch +import java.io.File +import java.io.IOException + +abstract class LocalSocketListener(name: String, socketFile: File) : Thread(name) { + private val localSocket = LocalSocket().apply { + socketFile.delete() // It's a must-have to close and reuse previous local socket. + bind(LocalSocketAddress(socketFile.absolutePath, LocalSocketAddress.Namespace.FILESYSTEM)) + } + private val serverSocket = LocalServerSocket(localSocket.fileDescriptor) + private val closeChannel = Channel(1) + @Volatile + protected var running = true + + /** + * Inherited class do not need to close input/output streams as they will be closed automatically. + */ + protected open fun accept(socket: LocalSocket) = socket.use { acceptInternal(socket) } + + protected abstract fun acceptInternal(socket: LocalSocket) + final override fun run() { + localSocket.use { + while (running) { + try { + accept(serverSocket.accept()) + } catch (e: IOException) { + if (running) printLog(e) + continue + } + } + } + closeChannel.sendBlocking(Unit) + } + + open fun shutdown(scope: CoroutineScope) { + running = false + localSocket.fileDescriptor?.apply { + // see also: https://issuetracker.google.com/issues/36945762#comment15 + if (valid()) try { + Os.shutdown(this, OsConstants.SHUT_RDWR) + } catch (e: ErrnoException) { + // suppress fd inactive or already closed + if (e.errno != OsConstants.EBADF && e.errno != OsConstants.ENOTCONN) throw IOException(e) + } + } + scope.launch { closeChannel.receive() } + } +} diff --git a/client/android/src/com/github/shadowsocks/net/Socks5Endpoint.kt b/client/android/src/com/github/shadowsocks/net/Socks5Endpoint.kt new file mode 100644 index 000000000..f3ac56834 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/Socks5Endpoint.kt @@ -0,0 +1,132 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import com.github.shadowsocks.utils.parseNumericAddress +import net.sourceforge.jsocks.Socks4Message +import net.sourceforge.jsocks.Socks5Message +import java.io.EOFException +import java.io.IOException +import java.net.Inet4Address +import java.net.Inet6Address +import java.nio.ByteBuffer +import kotlin.math.max + +class Socks5Endpoint(host: String, port: Int) { + private val dest = host.parseNumericAddress().let { numeric -> + val bytes = numeric?.address + ?: host.toByteArray().apply { check(size < 256) { "Hostname too long" } } + val type = when (numeric) { + null -> Socks5Message.SOCKS_ATYP_DOMAINNAME + is Inet4Address -> Socks5Message.SOCKS_ATYP_IPV4 + is Inet6Address -> Socks5Message.SOCKS_ATYP_IPV6 + else -> error("Unsupported address type $numeric") + } + ByteBuffer.allocate(bytes.size + (if (numeric == null) 1 else 0) + 3).apply { + put(type.toByte()) + if (numeric == null) put(bytes.size.toByte()) + put(bytes) + putShort(port.toShort()) + } + }.array() + private val headerReserved = max(3 + 3 + 16, 3 + dest.size) + + fun tcpWrap(message: ByteBuffer): ByteBuffer { + check(message.remaining() < 65536) { "TCP message too large" } + return ByteBuffer.allocateDirect(8 + dest.size + message.remaining()).apply { + put(Socks5Message.SOCKS_VERSION.toByte()) + put(1) // nmethods + put(0) // no authentication required + // header + put(Socks5Message.SOCKS_VERSION.toByte()) + put(Socks4Message.REQUEST_CONNECT.toByte()) + put(0) // reserved + put(dest) + // data + putShort(message.remaining().toShort()) + put(message) + flip() + } + } + + fun tcpReceiveBuffer(size: Int) = ByteBuffer.allocateDirect(headerReserved + 4 + size) + suspend fun tcpUnwrap(buffer: ByteBuffer, reader: (ByteBuffer) -> Int, wait: suspend () -> Unit) { + suspend fun readBytes(till: Int) { + if (buffer.position() >= till) return + while (reader(buffer) >= 0 && buffer.position() < till) wait() + if (buffer.position() < till) throw EOFException("${buffer.position()} < $till") + } + + suspend fun read(index: Int): Byte { + readBytes(index + 1) + return buffer[index] + } + if (read(0) != Socks5Message.SOCKS_VERSION.toByte()) throw IOException("Unsupported SOCKS version ${buffer[0]}") + if (read(1) != 0.toByte()) throw IOException("Unsupported authentication ${buffer[1]}") + if (read(2) != Socks5Message.SOCKS_VERSION.toByte()) throw IOException("Unsupported SOCKS version ${buffer[2]}") + if (read(3) != 0.toByte()) throw IOException("SOCKS5 server returned error ${buffer[3]}") + val dataOffset = when (val type = read(5)) { + Socks5Message.SOCKS_ATYP_IPV4.toByte() -> 4 + Socks5Message.SOCKS_ATYP_DOMAINNAME.toByte() -> 1 + read(6) + Socks5Message.SOCKS_ATYP_IPV6.toByte() -> 16 + else -> throw IOException("Unsupported address type $type") + } + 8 + readBytes(dataOffset + 2) + buffer.limit(buffer.position()) // store old position to update mark + buffer.position(dataOffset) + val dataLength = buffer.short.toUShort().toInt() + val end = buffer.position() + dataLength + if (end > buffer.capacity()) throw IOException("Buffer too small to contain the message: $dataLength > ${buffer.capacity() - buffer.position()}") + buffer.mark() + buffer.position(buffer.limit()) // restore old position + buffer.limit(end) + readBytes(buffer.limit()) + buffer.reset() + } + + private fun ByteBuffer.tryPosition(newPosition: Int) { + if (limit() < newPosition) throw EOFException("${limit()} < $newPosition") + position(newPosition) + } + + fun udpWrap(packet: ByteBuffer) = + ByteBuffer.allocateDirect(3 + dest.size + packet.remaining()).apply { + // header + putShort(0) // reserved + put(0) // fragment number + put(dest) + // data + put(packet) + flip() + } + + fun udpReceiveBuffer(size: Int) = ByteBuffer.allocateDirect(headerReserved + size) + fun udpUnwrap(packet: ByteBuffer) { + packet.tryPosition(3) + packet.tryPosition(6 + when (val type = packet.get()) { + Socks5Message.SOCKS_ATYP_IPV4.toByte() -> 4 + Socks5Message.SOCKS_ATYP_DOMAINNAME.toByte() -> 1 + packet.get() + Socks5Message.SOCKS_ATYP_IPV6.toByte() -> 16 + else -> throw IOException("Unsupported address type $type") + }) + packet.mark() + } +} diff --git a/client/android/src/com/github/shadowsocks/net/Subnet.kt b/client/android/src/com/github/shadowsocks/net/Subnet.kt new file mode 100644 index 000000000..9cff92c6c --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/Subnet.kt @@ -0,0 +1,85 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import com.github.shadowsocks.utils.parseNumericAddress +import java.net.InetAddress +import java.util.* + +class Subnet(val address: InetAddress, val prefixSize: Int) : Comparable { + companion object { + fun fromString(value: String): Subnet? { + @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") + val parts = (value as java.lang.String).split("/", 2) + val addr = parts[0].parseNumericAddress() ?: return null + return if (parts.size == 2) try { + val prefixSize = parts[1].toInt() + if (prefixSize < 0 || prefixSize > addr.address.size shl 3) null else Subnet(addr, prefixSize) + } catch (_: NumberFormatException) { + null + } else Subnet(addr, addr.address.size shl 3) + } + } + + private val addressLength get() = address.address.size shl 3 + + init { + require(prefixSize in 0..addressLength) { "prefixSize $prefixSize not in 0..$addressLength" } + } + + fun matches(other: InetAddress): Boolean { + if (address.javaClass != other.javaClass) return false + // TODO optimize? + val a = address.address + val b = other.address + var i = 0 + while (i * 8 < prefixSize && i * 8 + 8 <= prefixSize) { + if (a[i] != b[i]) return false + ++i + } + if (i * 8 == prefixSize) return true + val mask = 256 - (1 shl (i * 8 + 8 - prefixSize)) + return (a[i].toInt() and mask) == (b[i].toInt() and mask) + } + + override fun toString(): String = + if (prefixSize == addressLength) address.hostAddress else address.hostAddress + '/' + prefixSize + + private fun Byte.unsigned() = toInt() and 0xFF + override fun compareTo(other: Subnet): Int { + val addrThis = address.address + val addrThat = other.address.address + var result = addrThis.size.compareTo(addrThat.size) // IPv4 address goes first + if (result != 0) return result + for ((x, y) in addrThis zip addrThat) { + result = x.unsigned().compareTo(y.unsigned()) // undo sign extension of signed byte + if (result != 0) return result + } + return prefixSize.compareTo(other.prefixSize) + } + + override fun equals(other: Any?): Boolean { + val that = other as? Subnet + return address == that?.address && prefixSize == that.prefixSize + } + + override fun hashCode(): Int = Objects.hash(address, prefixSize) +} diff --git a/client/android/src/com/github/shadowsocks/net/TcpFastOpen.kt b/client/android/src/com/github/shadowsocks/net/TcpFastOpen.kt new file mode 100644 index 000000000..94aa753a5 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/net/TcpFastOpen.kt @@ -0,0 +1,68 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.net + +import com.github.shadowsocks.utils.readableMessage +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File +import java.io.IOException + +object TcpFastOpen { + private const val PATH = "/proc/sys/net/ipv4/tcp_fastopen" + + /** + * Is kernel version >= 3.7.1. + */ + val supported by lazy { + if (File(PATH).canRead()) return@lazy true + val match = + """^(\d+)\.(\d+)\.(\d+)""".toRegex().find(System.getProperty("os.version") ?: "") + if (match == null) false else when (match.groupValues[1].toInt()) { + in Int.MIN_VALUE..2 -> false + 3 -> when (match.groupValues[2].toInt()) { + in Int.MIN_VALUE..6 -> false + 7 -> match.groupValues[3].toInt() >= 1 + else -> true + } + else -> true + } + } + + val sendEnabled: Boolean + get() { + val file = File(PATH) + // File.readText doesn't work since this special file will return length 0 + // on Android containers like Chrome OS, this file does not exist so we simply judge by the kernel version + return if (file.canRead()) file.bufferedReader().use { it.readText() }.trim().toInt() and 1 > 0 else supported + } + + fun enable(): String? { + return try { + ProcessBuilder("su", "-c", "echo 3 > $PATH").redirectErrorStream(true).start() + .inputStream.bufferedReader().readText() + } catch (e: IOException) { + e.readableMessage + } + } + + fun enableTimeout() = runBlocking { withTimeoutOrNull(1000) { enable() } } +} diff --git a/client/android/src/com/github/shadowsocks/preference/DataStore.kt b/client/android/src/com/github/shadowsocks/preference/DataStore.kt new file mode 100644 index 000000000..3e7774768 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/preference/DataStore.kt @@ -0,0 +1,90 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import android.os.Binder +import androidx.preference.PreferenceDataStore +import com.github.shadowsocks.Core +import com.github.shadowsocks.database.PrivateDatabase +import com.github.shadowsocks.database.PublicDatabase +import com.github.shadowsocks.net.TcpFastOpen +import com.github.shadowsocks.utils.DirectBoot +import com.github.shadowsocks.utils.Key +import com.github.shadowsocks.utils.parsePort +import java.net.InetSocketAddress + +object DataStore : OnPreferenceDataStoreChangeListener { + val publicStore = RoomPreferenceDataStore(PublicDatabase.kvPairDao) + // privateStore will only be used as temp storage for ProfileConfigFragment + val privateStore = RoomPreferenceDataStore(PrivateDatabase.kvPairDao) + + init { + publicStore.registerChangeListener(this) + } + + override fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) { + when (key) { + Key.id -> if (directBootAware) DirectBoot.update() + } + } + + // hopefully hashCode = mHandle doesn't change, currently this is true from KitKat to Nougat + private val userIndex by lazy { Binder.getCallingUserHandle().hashCode() } + + private fun getLocalPort(key: String, default: Int): Int { + val value = publicStore.getInt(key) + return if (value != null) { + publicStore.putString(key, value.toString()) + value + } else parsePort(publicStore.getString(key), default + userIndex) + } + + var profileId: Long + get() = publicStore.getLong(Key.id) ?: 0 + set(value) = publicStore.putLong(Key.id, value) + val canToggleLocked: Boolean get() = publicStore.getBoolean(Key.directBootAware) == true + val directBootAware: Boolean get() = Core.directBootSupported && canToggleLocked + val tcpFastOpen: Boolean get() = TcpFastOpen.sendEnabled && publicStore.getBoolean(Key.tfo, false) + val listenAddress get() = "127.0.0.1" + var portProxy: Int + get() = getLocalPort(Key.portProxy, 1080) + set(value) = publicStore.putString(Key.portProxy, value.toString()) + val proxyAddress get() = InetSocketAddress("127.0.0.1", portProxy) + var portLocalDns: Int + get() = getLocalPort(Key.portLocalDns, 5450) + set(value) = publicStore.putString(Key.portLocalDns, value.toString()) + + /** + * Initialize settings that have complicated default values. + */ + fun initGlobal() { + if (publicStore.getBoolean(Key.tfo) == null) publicStore.putBoolean(Key.tfo, tcpFastOpen) + if (publicStore.getString(Key.portProxy) == null) portProxy = portProxy + if (publicStore.getString(Key.portLocalDns) == null) portLocalDns = portLocalDns + } + + var editingId: Long? + get() = privateStore.getLong(Key.id) + set(value) = privateStore.putLong(Key.id, value) + var dirty: Boolean + get() = privateStore.getBoolean(Key.dirty) ?: false + set(value) = privateStore.putBoolean(Key.dirty, value) +} diff --git a/client/android/src/com/github/shadowsocks/preference/EditTextPreferenceModifiers.kt b/client/android/src/com/github/shadowsocks/preference/EditTextPreferenceModifiers.kt new file mode 100644 index 000000000..1385c77ed --- /dev/null +++ b/client/android/src/com/github/shadowsocks/preference/EditTextPreferenceModifiers.kt @@ -0,0 +1,46 @@ +/******************************************************************************* + * * + * Copyright (C) 2019 by Max Lv * + * Copyright (C) 2019 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import android.graphics.Typeface +import android.text.InputFilter +import android.view.inputmethod.EditorInfo +import android.widget.EditText +import androidx.preference.EditTextPreference + +object EditTextPreferenceModifiers { + object Monospace : EditTextPreference.OnBindEditTextListener { + override fun onBindEditText(editText: EditText) { + editText.typeface = Typeface.MONOSPACE + } + } + + object Port : EditTextPreference.OnBindEditTextListener { + private val portLengthFilter = arrayOf(InputFilter.LengthFilter(5)) + + override fun onBindEditText(editText: EditText) { + editText.inputType = EditorInfo.TYPE_CLASS_NUMBER + editText.filters = portLengthFilter + editText.setSingleLine() + editText.setSelection(editText.text.length) + } + } +} diff --git a/client/android/src/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt b/client/android/src/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt new file mode 100644 index 000000000..e18484b33 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/preference/OnPreferenceDataStoreChangeListener.kt @@ -0,0 +1,27 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import androidx.preference.PreferenceDataStore + +interface OnPreferenceDataStoreChangeListener { + fun onPreferenceDataStoreChanged(store: PreferenceDataStore, key: String) +} diff --git a/client/android/src/com/github/shadowsocks/preference/RoomPreferenceDataStore.kt b/client/android/src/com/github/shadowsocks/preference/RoomPreferenceDataStore.kt new file mode 100644 index 000000000..4afbafbb1 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/preference/RoomPreferenceDataStore.kt @@ -0,0 +1,100 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.preference + +import androidx.preference.PreferenceDataStore +import com.github.shadowsocks.database.KeyValuePair +import java.util.* + +@Suppress("MemberVisibilityCanBePrivate", "unused") +open class RoomPreferenceDataStore(private val kvPairDao: KeyValuePair.Dao) : + PreferenceDataStore() { + fun getBoolean(key: String) = kvPairDao[key]?.boolean + fun getFloat(key: String) = kvPairDao[key]?.float + fun getInt(key: String) = kvPairDao[key]?.long?.toInt() + fun getLong(key: String) = kvPairDao[key]?.long + fun getString(key: String) = kvPairDao[key]?.string + fun getStringSet(key: String) = kvPairDao[key]?.stringSet + + override fun getBoolean(key: String, defValue: Boolean) = getBoolean(key) ?: defValue + override fun getFloat(key: String, defValue: Float) = getFloat(key) ?: defValue + override fun getInt(key: String, defValue: Int) = getInt(key) ?: defValue + override fun getLong(key: String, defValue: Long) = getLong(key) ?: defValue + override fun getString(key: String, defValue: String?) = getString(key) ?: defValue + override fun getStringSet(key: String, defValue: MutableSet?) = + getStringSet(key) ?: defValue + + fun putBoolean(key: String, value: Boolean?) = + if (value == null) remove(key) else putBoolean(key, value) + + fun putFloat(key: String, value: Float?) = + if (value == null) remove(key) else putFloat(key, value) + + fun putInt(key: String, value: Int?) = + if (value == null) remove(key) else putLong(key, value.toLong()) + + fun putLong(key: String, value: Long?) = if (value == null) remove(key) else putLong(key, value) + override fun putBoolean(key: String, value: Boolean) { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putFloat(key: String, value: Float) { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putInt(key: String, value: Int) { + kvPairDao.put(KeyValuePair(key).put(value.toLong())) + fireChangeListener(key) + } + + override fun putLong(key: String, value: Long) { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putString(key: String, value: String?) = if (value == null) remove(key) else { + kvPairDao.put(KeyValuePair(key).put(value)) + fireChangeListener(key) + } + + override fun putStringSet(key: String, values: MutableSet?) = + if (values == null) remove(key) else { + kvPairDao.put(KeyValuePair(key).put(values)) + fireChangeListener(key) + } + + fun remove(key: String) { + kvPairDao.delete(key) + fireChangeListener(key) + } + + private val listeners = HashSet() + private fun fireChangeListener(key: String) = + listeners.forEach { it.onPreferenceDataStoreChanged(this, key) } + + fun registerChangeListener(listener: OnPreferenceDataStoreChangeListener) = + listeners.add(listener) + + fun unregisterChangeListener(listener: OnPreferenceDataStoreChangeListener) = + listeners.remove(listener) +} diff --git a/client/android/src/com/github/shadowsocks/utils/ArrayIterator.kt b/client/android/src/com/github/shadowsocks/utils/ArrayIterator.kt new file mode 100644 index 000000000..6b789bf54 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/utils/ArrayIterator.kt @@ -0,0 +1,46 @@ +/******************************************************************************* + * * + * Copyright (C) 2018 by Max Lv * + * Copyright (C) 2018 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +import android.content.ClipData +import androidx.recyclerview.widget.SortedList + +private sealed class ArrayIterator : Iterator { + abstract val size: Int + abstract operator fun get(index: Int): T + private var count = 0 + override fun hasNext() = count < size + override fun next(): T = if (hasNext()) this[count++] else throw NoSuchElementException() +} + +private class ClipDataIterator(private val data: ClipData) : ArrayIterator() { + override val size get() = data.itemCount + override fun get(index: Int) = data.getItemAt(index) +} + +fun ClipData.asIterable() = Iterable { ClipDataIterator(this) } + +private class SortedListIterator(private val list: SortedList) : ArrayIterator() { + override val size get() = list.size() + override fun get(index: Int) = list[index] +} + +fun SortedList.asIterable() = Iterable { SortedListIterator(this) } diff --git a/client/android/src/com/github/shadowsocks/utils/Constants.kt b/client/android/src/com/github/shadowsocks/utils/Constants.kt new file mode 100644 index 000000000..87328ff82 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/utils/Constants.kt @@ -0,0 +1,60 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +object Key { + /** + * Public config that doesn't need to be kept secret. + */ + const val DB_PUBLIC = "config.db" + const val DB_PROFILE = "profile.db" + + const val id = "profileId" + const val name = "profileName" + + const val portProxy = "portProxy" + const val portLocalDns = "portLocalDns" + + const val directBootAware = "directBootAware" + + const val udpdns = "isUdpDns" + const val ipv6 = "isIpv6" + + const val host = "proxy" + const val password = "sitekey" + const val method = "encMethod" + const val remotePort = "remotePortNum" + const val remoteDns = "remoteDns" + + const val dirty = "profileDirty" + + const val tfo = "tcp_fastopen" + const val hosts = "hosts" + const val assetUpdateTime = "assetUpdateTime" +} + +object Action { + const val SERVICE = "com.github.shadowsocks.SERVICE" + const val CLOSE = "com.github.shadowsocks.CLOSE" + const val RELOAD = "com.github.shadowsocks.RELOAD" + + const val EXTRA_PROFILE_ID = "com.github.shadowsocks.EXTRA_PROFILE_ID" +} diff --git a/client/android/src/com/github/shadowsocks/utils/DeviceStorageApp.kt b/client/android/src/com/github/shadowsocks/utils/DeviceStorageApp.kt new file mode 100644 index 000000000..9dd73e481 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/utils/DeviceStorageApp.kt @@ -0,0 +1,40 @@ +/******************************************************************************* + * * + * Copyright (C) 2017 by Max Lv * + * Copyright (C) 2017 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.app.Application +import android.content.Context + +@SuppressLint("Registered") +@TargetApi(24) +class DeviceStorageApp(context: Context) : Application() { + init { + attachBaseContext(context.createDeviceProtectedStorageContext()) + } + + /** + * Thou shalt not get the REAL underlying application context which would no longer be operating under device + * protected storage. + */ + override fun getApplicationContext() = this +} diff --git a/client/android/src/com/github/shadowsocks/utils/DirectBoot.kt b/client/android/src/com/github/shadowsocks/utils/DirectBoot.kt new file mode 100644 index 000000000..fda29f45c --- /dev/null +++ b/client/android/src/com/github/shadowsocks/utils/DirectBoot.kt @@ -0,0 +1,64 @@ +package com.github.shadowsocks.utils + +import android.annotation.TargetApi +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import com.github.shadowsocks.Core +import com.github.shadowsocks.Core.app +import com.github.shadowsocks.bg.BaseService +import com.github.shadowsocks.database.Profile +import com.github.shadowsocks.database.ProfileManager +import com.github.shadowsocks.preference.DataStore +import java.io.File +import java.io.IOException +import java.io.ObjectInputStream +import java.io.ObjectOutputStream + +@TargetApi(24) +object DirectBoot : BroadcastReceiver() { + private val file = File(Core.deviceStorage.noBackupFilesDir, "directBootProfile") + private var registered = false + + fun getDeviceProfile(): Pair? = try { + ObjectInputStream(file.inputStream()).use { it.readObject() as? Pair } + } catch (_: IOException) { + null + } + + fun clean() { + file.delete() + File(Core.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE).delete() + File(Core.deviceStorage.noBackupFilesDir, BaseService.CONFIG_FILE_UDP).delete() + } + + /** + * app.currentProfile will call this. + */ + fun update(profile: Profile? = ProfileManager.getProfile(DataStore.profileId)) = + if (profile == null) clean() + else ObjectOutputStream(file.outputStream()).use { + it.writeObject(ProfileManager.expand(profile)) + } + + fun flushTrafficStats() { + getDeviceProfile()?.also { (profile, fallback) -> + if (profile.dirty) ProfileManager.updateProfile(profile) + if (fallback?.dirty == true) ProfileManager.updateProfile(fallback) + } + update() + } + + fun listenForUnlock() { + if (registered) return + app.registerReceiver(this, IntentFilter(Intent.ACTION_BOOT_COMPLETED)) + registered = true + } + + override fun onReceive(context: Context, intent: Intent) { + flushTrafficStats() + app.unregisterReceiver(this) + registered = false + } +} diff --git a/client/android/src/com/github/shadowsocks/utils/Utils.kt b/client/android/src/com/github/shadowsocks/utils/Utils.kt new file mode 100644 index 000000000..a465f6d44 --- /dev/null +++ b/client/android/src/com/github/shadowsocks/utils/Utils.kt @@ -0,0 +1,133 @@ +/******************************************************************************* + * * + * Copyright (C) 2018 by Max Lv * + * Copyright (C) 2018 by Mygod Studio * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * This program is distributed in the hope that it will be useful, * + * but WITHOUT ANY WARRANTY; without even the implied warranty of * + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * + * GNU General Public License for more details. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + * * + *******************************************************************************/ + +package com.github.shadowsocks.utils + +import android.annotation.SuppressLint +import android.content.BroadcastReceiver +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInfo +import android.content.res.Resources +import android.graphics.BitmapFactory +import android.graphics.ImageDecoder +import android.net.Uri +import android.os.Build +import android.system.Os +import android.system.OsConstants +import android.util.TypedValue +import androidx.annotation.AttrRes +import androidx.preference.Preference +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.net.HttpURLConnection +import java.net.InetAddress +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +fun Iterable.forEachTry(action: (T) -> Unit) { + var result: Exception? = null + for (element in this) try { + action(element) + } catch (e: Exception) { + if (result == null) result = e else result.addSuppressed(e) + } + if (result != null) { + result.printStackTrace() + throw result + } +} + +val Throwable.readableMessage get() = localizedMessage ?: javaClass.name + +private val parseNumericAddress by lazy @SuppressLint("DiscouragedPrivateApi") { + InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply { + isAccessible = true + } +} + +/** + * A slightly more performant variant of parseNumericAddress. + * + * Bug in Android 9.0 and lower: https://issuetracker.google.com/issues/123456213 + */ +fun String?.parseNumericAddress(): InetAddress? = + Os.inet_pton(OsConstants.AF_INET, this) ?: Os.inet_pton(OsConstants.AF_INET6, this)?.let { + if (Build.VERSION.SDK_INT >= 29) it else parseNumericAddress.invoke(null, + this) as InetAddress + } + +fun MutableMap.computeIfAbsentCompat(key: K, value: () -> V) = + if (Build.VERSION.SDK_INT >= 24) computeIfAbsent(key) { value() } else this[key] + ?: value().also { put(key, it) } + +suspend fun HttpURLConnection.useCancellable(block: suspend HttpURLConnection.() -> T): T { + return suspendCancellableCoroutine { cont -> + cont.invokeOnCancellation { + if (Build.VERSION.SDK_INT >= 26) disconnect() else GlobalScope.launch(Dispatchers.IO) { disconnect() } + } + GlobalScope.launch(Dispatchers.IO) { + try { + cont.resume(block()) + } catch (e: Throwable) { + cont.resumeWithException(e) + } + } + } +} + +fun parsePort(str: String?, default: Int, min: Int = 1025): Int { + val value = str?.toIntOrNull() ?: default + return if (value < min || value > 65535) default else value +} + +fun broadcastReceiver(callback: (Context, Intent) -> Unit): BroadcastReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) = callback(context, intent) + } + +fun ContentResolver.openBitmap(uri: Uri) = + if (Build.VERSION.SDK_INT >= 28) ImageDecoder.decodeBitmap(ImageDecoder.createSource(this, uri)) + else BitmapFactory.decodeStream(openInputStream(uri)) + +val PackageInfo.signaturesCompat + get() = if (Build.VERSION.SDK_INT >= 28) signingInfo.apkContentsSigners else @Suppress("DEPRECATION") signatures + +/** + * Based on: https://stackoverflow.com/a/26348729/2245107 + */ +fun Resources.Theme.resolveResourceId(@AttrRes resId: Int): Int { + val typedValue = TypedValue() + if (!resolveAttribute(resId, typedValue, true)) throw Resources.NotFoundException() + return typedValue.resourceId +} + +val Intent.datas + get() = listOfNotNull(data) + (clipData?.asIterable()?.mapNotNull { it.uri } ?: emptyList()) + +fun printLog(t: Throwable) { + // Crashlytics.logException(t) + t.printStackTrace() +} + +fun Preference.remove() = parent!!.removePreference(this)