diff --git a/.circleci/config.yml b/.circleci/config.yml index 8b34d1be34..7d7e12b13f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -4,6 +4,7 @@ jobs: working_directory: ~/code docker: - image: cimg/android:2022.12.1-ndk + # ref: https://circleci.com/developer/images/image/cimg/android environment: GRADLE_OPTS: -Dorg.gradle.workers.max=1 -Dorg.gradle.daemon=false -Dkotlin.compiler.execution.strategy="in-process" RUST_VERSION: 1.67.0 diff --git a/core/build.gradle.kts b/core/build.gradle.kts index b988429105..66f02bc74c 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -106,6 +106,8 @@ dependencies { api("org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutinesVersion") api("org.jetbrains.kotlinx:kotlinx-coroutines-play-services:$coroutinesVersion") kapt("androidx.room:room-compiler:$roomVersion") + api("com.squareup.okhttp3:okhttp:5.0.0-alpha.10") + api("com.squareup.okhttp3:okhttp-brotli:5.0.0-alpha.10") androidTestImplementation("androidx.room:room-testing:$roomVersion") androidTestImplementation("androidx.test.ext:junit-ktx:1.1.5") } diff --git a/core/schemas/com.github.shadowsocks.database.PrivateDatabase/30.json b/core/schemas/com.github.shadowsocks.database.PrivateDatabase/30.json new file mode 100644 index 0000000000..45058507a1 --- /dev/null +++ b/core/schemas/com.github.shadowsocks.database.PrivateDatabase/30.json @@ -0,0 +1,186 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "e1c2ad386fb06fcb7c9090f43b712f1d", + "entities": [ + { + "tableName": "Profile", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`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, `route` TEXT NOT NULL, `remoteDns` TEXT NOT NULL, `localDohHostAddr` TEXT NOT NULL, `proxyApps` INTEGER NOT NULL, `bypass` INTEGER NOT NULL, `udpdns` INTEGER NOT NULL, `ipv6` INTEGER NOT NULL, `metered` INTEGER NOT NULL, `individual` TEXT NOT NULL, `plugin` TEXT, `udpFallback` INTEGER, `subscription` INTEGER NOT NULL, `tx` INTEGER NOT NULL, `rx` INTEGER NOT NULL, `userOrder` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "host", + "columnName": "host", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remotePort", + "columnName": "remotePort", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "password", + "columnName": "password", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "method", + "columnName": "method", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "route", + "columnName": "route", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "remoteDns", + "columnName": "remoteDns", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "localDohHostAddr", + "columnName": "localDohHostAddr", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "proxyApps", + "columnName": "proxyApps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bypass", + "columnName": "bypass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "udpdns", + "columnName": "udpdns", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "ipv6", + "columnName": "ipv6", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metered", + "columnName": "metered", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "individual", + "columnName": "individual", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "plugin", + "columnName": "plugin", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "udpFallback", + "columnName": "udpFallback", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "subscription", + "columnName": "subscription", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tx", + "columnName": "tx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rx", + "columnName": "rx", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userOrder", + "columnName": "userOrder", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "KeyValuePair", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `valueType` INTEGER NOT NULL, `value` BLOB NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueType", + "columnName": "valueType", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "key" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'e1c2ad386fb06fcb7c9090f43b712f1d')" + ] + } +} \ No newline at end of file diff --git a/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt b/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt index 2a0bcd7950..603a3fcd1e 100644 --- a/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt +++ b/core/src/main/java/com/github/shadowsocks/database/PrivateDatabase.kt @@ -32,7 +32,7 @@ import com.github.shadowsocks.utils.Key import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch -@Database(entities = [Profile::class, KeyValuePair::class], version = 29) +@Database(entities = [Profile::class, KeyValuePair::class], version = 30) @TypeConverters(Profile.SubscriptionStatus::class) abstract class PrivateDatabase : RoomDatabase() { companion object { @@ -42,7 +42,8 @@ abstract class PrivateDatabase : RoomDatabase() { Migration26, Migration27, Migration28, - Migration29 + Migration29, + Migration30, ) allowMainThreadQueries() enableMultiInstanceInvalidation() @@ -78,4 +79,8 @@ abstract class PrivateDatabase : RoomDatabase() { database.execSQL("ALTER TABLE `Profile` ADD COLUMN `subscription` INTEGER NOT NULL DEFAULT " + Profile.SubscriptionStatus.UserConfigured.persistedValue) } + object Migration30 : Migration(29, 30) { + override fun migrate(database: SupportSQLiteDatabase) = + database.execSQL("ALTER TABLE `Profile` ADD COLUMN `localDohHostAddr` TEXT NOT NULL DEFAULT ''") + } } diff --git a/core/src/main/java/com/github/shadowsocks/database/Profile.kt b/core/src/main/java/com/github/shadowsocks/database/Profile.kt index bae60e1066..17de95f0c3 100644 --- a/core/src/main/java/com/github/shadowsocks/database/Profile.kt +++ b/core/src/main/java/com/github/shadowsocks/database/Profile.kt @@ -27,6 +27,7 @@ import android.util.Base64 import android.util.LongSparseArray import androidx.core.net.toUri import androidx.room.* +import com.github.shadowsocks.acl.Acl import com.github.shadowsocks.plugin.PluginConfiguration import com.github.shadowsocks.plugin.PluginOptions import com.github.shadowsocks.preference.DataStore @@ -59,8 +60,9 @@ data class Profile( var password: String = "u1rRWTssNv0p", var method: String = "aes-256-cfb", - var route: String = "all", + var route: String = Acl.ALL, var remoteDns: String = "dns.google", + var localDohHostAddr: String = "223.5.5.5,120.53.53.53,223.6.6.6,1.12.12.12", var proxyApps: Boolean = false, var bypass: Boolean = false, var udpdns: Boolean = false, @@ -197,6 +199,7 @@ data class Profile( route = json["route"].optString ?: route if (fallback) return@apply remoteDns = json["remote_dns"].optString ?: remoteDns + localDohHostAddr = json["localDohHostAddr"].optString ?: localDohHostAddr ipv6 = json["ipv6"].optBoolean ?: ipv6 metered = json["metered"].optBoolean ?: metered (json["proxy_apps"] as? JsonObject)?.also { @@ -323,6 +326,7 @@ data class Profile( put("remarks", name) put("route", route) put("remote_dns", remoteDns) + put("localDohHostAddr", localDohHostAddr) put("ipv6", ipv6) put("metered", metered) put("proxy_apps", JSONObject().apply { @@ -346,6 +350,7 @@ data class Profile( DataStore.privateStore.putString(Key.password, password) DataStore.privateStore.putString(Key.route, route) DataStore.privateStore.putString(Key.remoteDns, remoteDns) + DataStore.privateStore.putString(Key.localDohHostAddr, localDohHostAddr) DataStore.privateStore.putString(Key.method, method) DataStore.proxyApps = proxyApps DataStore.bypass = bypass @@ -370,6 +375,7 @@ data class Profile( method = DataStore.privateStore.getString(Key.method) ?: "" route = DataStore.privateStore.getString(Key.route) ?: "" remoteDns = DataStore.privateStore.getString(Key.remoteDns) ?: "" + localDohHostAddr = DataStore.privateStore.getString(Key.localDohHostAddr) ?: "" proxyApps = DataStore.proxyApps bypass = DataStore.bypass udpdns = DataStore.privateStore.getBoolean(Key.udpdns, false) diff --git a/core/src/main/java/com/github/shadowsocks/net/DnsResolverCompat.kt b/core/src/main/java/com/github/shadowsocks/net/DnsResolverCompat.kt index c65491c067..7edfb98812 100644 --- a/core/src/main/java/com/github/shadowsocks/net/DnsResolverCompat.kt +++ b/core/src/main/java/com/github/shadowsocks/net/DnsResolverCompat.kt @@ -20,6 +20,7 @@ package com.github.shadowsocks.net +import android.annotation.SuppressLint import android.annotation.TargetApi import android.net.DnsResolver import android.net.Network @@ -32,6 +33,7 @@ import java.io.IOException import java.net.Inet4Address import java.net.Inet6Address import java.net.InetAddress +import java.time.Duration import java.util.concurrent.Executor import java.util.concurrent.Executors import kotlin.coroutines.resume @@ -46,6 +48,32 @@ sealed class DnsResolverCompat { else -> error("Unsupported API level") } } + private val isDohResolve get() = Core.currentProfile!!.main.localDohHostAddr.isEmpty().not() + private val dohResolver by lazy { + val fakeReslver = SimpleResolver() + val expanded = Core.currentProfile ?: return@lazy fakeReslver + val (profile, _) = expanded + val doh = profile.localDohHostAddr + if (doh.isEmpty()) { + return@lazy fakeReslver + } + val addrList = doh.split(",") + if (addrList.isEmpty()) { + return@lazy fakeReslver + } + val dohResolvers = arrayListOf() + val exec = Executors.newFixedThreadPool(5) + val activeNetwork = Core.connectivity.activeNetwork ?: throw IOException("no network") + addrList.forEach { + dohResolvers.add(DohResolver(activeNetwork , it, 5, Duration.ofSeconds(5), exec)) + } + ExtendedResolver(dohResolvers).apply { + Lookup.setDefaultResolver(this) +// timeout = Duration.ofSeconds(3) +// retries = 2 + loadBalance = true + } + } override suspend fun resolve(network: Network, host: String) = instance.resolve(network, host) override suspend fun resolveOnActiveNetwork(host: String) = instance.resolveOnActiveNetwork(host) @@ -85,9 +113,9 @@ sealed class DnsResolverCompat { } override suspend fun resolve(network: Network, host: String) = - withContext(unboundedIO) { network.getAllByName(host) } + withContext(unboundedIO) { network.getAllByName(host) } override suspend fun resolveOnActiveNetwork(host: String) = - withContext(unboundedIO) { InetAddress.getAllByName(host) } + withContext(unboundedIO) { InetAddress.getAllByName(host) } private suspend fun resolveRaw(query: ByteArray, networkSpecified: Boolean = true, hostResolver: suspend (String) -> Array): ByteArray { @@ -133,9 +161,9 @@ sealed class DnsResolverCompat { }.toWire() } override suspend fun resolveRaw(network: Network, query: ByteArray) = - resolveRaw(query) { resolve(network, it) } + resolveRaw(query) { resolve(network, it) } override suspend fun resolveRawOnActiveNetwork(query: ByteArray) = - resolveRaw(query, false, this::resolveOnActiveNetwork) + resolveRaw(query, false, this::resolveOnActiveNetwork) } @TargetApi(29) @@ -153,24 +181,37 @@ sealed class DnsResolverCompat { 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) = + 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 fun onError(error: DnsResolver.DnsException) = cont.resumeWithException(IOException(error)) + }) } } override suspend fun resolveOnActiveNetwork(host: String) = resolve(activeNetwork, host) override suspend fun resolveRaw(network: Network, query: ByteArray): ByteArray { + val msg = Message(query) return suspendCancellableCoroutine { cont -> val signal = CancellationSignal() cont.invokeOnCancellation { signal.cancel() } - DnsResolver.getInstance().rawQuery(network, query, DnsResolver.FLAG_NO_RETRY, this, + if (isDohResolve) { + dohResolver.sendAsync(msg, this).whenCompleteAsync ({ answer, exception -> + if (null == exception) { + cont.resume(answer.toWire()) +// println("dns query response: $answer") + } else { + cont.resumeWithException(IOException(exception)) + } + }, this) + } else { + DnsResolver.getInstance().rawQuery(network, query, DnsResolver.FLAG_NO_RETRY, this, signal, object : DnsResolver.Callback { - override fun onAnswer(answer: ByteArray, rcode: Int) = cont.resume(answer) - override fun onError(error: DnsResolver.DnsException) = cont.resumeWithException(IOException(error)) - }) + override fun onAnswer(answer: ByteArray, rcode: Int) = cont.resume(answer) + override fun onError(error: DnsResolver.DnsException) = + cont.resumeWithException(IOException(error)) + }) + } } } override suspend fun resolveRawOnActiveNetwork(query: ByteArray) = resolveRaw(activeNetwork, query) diff --git a/core/src/main/java/com/github/shadowsocks/net/DohResolver.kt b/core/src/main/java/com/github/shadowsocks/net/DohResolver.kt new file mode 100644 index 0000000000..534e6bf456 --- /dev/null +++ b/core/src/main/java/com/github/shadowsocks/net/DohResolver.kt @@ -0,0 +1,96 @@ +package com.github.shadowsocks.net + +import android.net.Network +import android.os.Build +import androidx.annotation.RequiresApi +import okhttp3.* +import okhttp3.CacheControl.Companion.FORCE_NETWORK +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import org.xbill.DNS.EDNSOption +import org.xbill.DNS.Message +import org.xbill.DNS.Resolver +import org.xbill.DNS.TSIG +import java.io.IOException +import java.time.Duration +import java.util.* +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CompletionStage +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService + +class DohResolver(network: Network, host: String, maxConn: Int, private var timeout: Duration?, executor: ExecutorService) : Resolver { + + private val client: OkHttpClient + private val uriTemplate: String + override fun getTimeout(): Duration? { + return this.timeout + } + + init { + uriTemplate = "https://$host/dns-query" + val spec: ConnectionSpec = ConnectionSpec.MODERN_TLS + val dispatcher = Dispatcher(executor) + dispatcher.maxRequestsPerHost = maxConn + this.client = OkHttpClient.Builder() + .socketFactory(network.socketFactory) + .connectionSpecs(listOf(spec)) + .dispatcher(dispatcher) + .callTimeout(timeout!!) + .build() + } + + @RequiresApi(api = Build.VERSION_CODES.S) + override fun sendAsync(query: Message, executor: Executor): CompletionStage { + return this.sendAsync(query) + } + + @RequiresApi(api = Build.VERSION_CODES.N) + override fun sendAsync(query: Message): CompletionStage { +// println("OKHttp...$uriTemplate") + val bytes = query.toWire() + val body: RequestBody = bytes.toRequestBody(mediaType, 0, bytes.size) + val request: Request = Request.Builder() + .url(uriTemplate) + .cacheControl(FORCE_NETWORK) + .post(body) + .build() + val future = CompletableFuture() + val call = client.newCall(request) + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + future.completeExceptionally(e) + } + + @Throws(IOException::class) + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + future.cancel(true) + return@use + } + it.body.use { responseBody -> + val msg = Message(responseBody.bytes()) + future.complete(msg) + } + } + } + }) + return future + } + + override fun setPort(port: Int) {} + override fun setTCP(flag: Boolean) {} + override fun setIgnoreTruncation(flag: Boolean) {} + override fun setEDNS(version: Int, payloadSize: Int, flags: Int, options: List) {} + override fun setTSIGKey(key: TSIG) {} + override fun setTimeout(timeout: Duration?) { + this.timeout = timeout; + } +// override fun setTimeout(timeout: Duration?) { +// } + + companion object { + private val mediaType: MediaType = "application/dns-message".toMediaType() + } +} \ No newline at end of file diff --git a/core/src/main/java/com/github/shadowsocks/utils/Constants.kt b/core/src/main/java/com/github/shadowsocks/utils/Constants.kt index 52b6de20ec..d188df1c21 100644 --- a/core/src/main/java/com/github/shadowsocks/utils/Constants.kt +++ b/core/src/main/java/com/github/shadowsocks/utils/Constants.kt @@ -57,6 +57,7 @@ object Key { const val method = "encMethod" const val remotePort = "remotePortNum" const val remoteDns = "remoteDns" + const val localDohHostAddr = "localDohHostAddr" const val plugin = "plugin" const val pluginConfigure = "plugin.configure" diff --git a/core/src/main/res/values/strings.xml b/core/src/main/res/values/strings.xml index 04ed7d9d67..13c2aa2737 100644 --- a/core/src/main/res/values/strings.xml +++ b/core/src/main/res/values/strings.xml @@ -16,6 +16,7 @@ Transproxy port Remote DNS + Query Local DNS over DOH %1$s↑\t%2$s↓ Sent: \t\t\t\t\t%3$s\t↑\t%1$s\nReceived: \t%4$s\t↓\t%2$s %s/s diff --git a/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt index 8ca2d21c4d..4fa23de78d 100644 --- a/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt +++ b/mobile/src/main/java/com/github/shadowsocks/ProfileConfigFragment.kt @@ -96,6 +96,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), addPreferencesFromResource(R.xml.pref_profile) findPreference(Key.remotePort)!!.setOnBindEditTextListener(EditTextPreferenceModifiers.Port) findPreference(Key.password)!!.summaryProvider = PasswordSummaryProvider + findPreference(Key.localDohHostAddr)!!.setOnBindEditTextListener(EditTextPreferenceModifiers.Monospace) val serviceMode = DataStore.serviceMode findPreference(Key.ipv6)!!.isEnabled = serviceMode == Key.modeVpn isProxyApps = findPreference(Key.proxyApps)!! @@ -124,6 +125,7 @@ class ProfileConfigFragment : PreferenceFragmentCompat(), findPreference(Key.password)!!.isEnabled = false findPreference(Key.method)!!.isEnabled = false findPreference(Key.remotePort)!!.isEnabled = false + findPreference(Key.localDohHostAddr)!!.isEnabled = false plugin.isEnabled = false pluginConfigure.isEnabled = false udpFallback.isEnabled = false diff --git a/mobile/src/main/res/drawable/ic_action_doh.xml b/mobile/src/main/res/drawable/ic_action_doh.xml new file mode 100644 index 0000000000..e4b1b65f0e --- /dev/null +++ b/mobile/src/main/res/drawable/ic_action_doh.xml @@ -0,0 +1,28 @@ + + + + + + + + + diff --git a/mobile/src/main/res/xml/pref_profile.xml b/mobile/src/main/res/xml/pref_profile.xml index 5db0dd19a3..179a45bb35 100644 --- a/mobile/src/main/res/xml/pref_profile.xml +++ b/mobile/src/main/res/xml/pref_profile.xml @@ -66,6 +66,11 @@ app:icon="@drawable/ic_action_dns" app:title="@string/remote_dns" app:useSimpleSummaryProvider="true"/> + diff --git a/repositories.gradle.kts b/repositories.gradle.kts index f395f8c722..dd306fef9b 100644 --- a/repositories.gradle.kts +++ b/repositories.gradle.kts @@ -5,5 +5,5 @@ rootProject.extra.apply { repositories { google() - jcenter() + mavenCentral() }