DnsSpeedTest.kt 15.3 KB
Newer Older
1
2
package com.frostnerd.smokescreen.util.speedtest

3
import android.content.Context
4
5
6
import androidx.annotation.IntRange
import com.frostnerd.dnstunnelproxy.DnsServerInformation
import com.frostnerd.dnstunnelproxy.UpstreamAddress
Daniel Wolf's avatar
Daniel Wolf committed
7
8
import com.frostnerd.encrypteddnstunnelproxy.HttpsDnsServerInformation
import com.frostnerd.encrypteddnstunnelproxy.ServerConfiguration
Daniel Wolf's avatar
Daniel Wolf committed
9
10
import com.frostnerd.encrypteddnstunnelproxy.closeSilently
import com.frostnerd.encrypteddnstunnelproxy.quic.QuicUpstreamAddress
11
import com.frostnerd.encrypteddnstunnelproxy.tls.TLSUpstreamAddress
12
import com.frostnerd.smokescreen.createHttpCronetEngineIfInstalled
Daniel Wolf's avatar
Daniel Wolf committed
13
14
import com.frostnerd.smokescreen.type
import com.frostnerd.smokescreen.util.ServerType
15
import okhttp3.*
16
import okhttp3.HttpUrl.Companion.toHttpUrl
17
import okhttp3.MediaType.Companion.toMediaTypeOrNull
Daniel Wolf's avatar
Daniel Wolf committed
18
import okhttp3.RequestBody.Companion.toRequestBody
Daniel Wolf's avatar
Daniel Wolf committed
19
import org.chromium.net.CronetEngine
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import org.minidns.dnsmessage.DnsMessage
import org.minidns.dnsmessage.Question
import org.minidns.record.Record
import java.io.DataInputStream
import java.io.DataOutputStream
import java.net.*
import java.util.concurrent.TimeUnit
import javax.net.ssl.SSLSocketFactory
import kotlin.random.Random

/*
 * Copyright (C) 2019 Daniel Wolf (Ch4t4r)
 * 
 * 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 <http://www.gnu.org/licenses/>.
 * 
 * You can contact the developer at daniel.wolf@frostnerd.com.
 */

49
50
class DnsSpeedTest(context:Context,
                   val server: DnsServerInformation<*>,
Daniel Wolf's avatar
Daniel Wolf committed
51
52
                   private val connectTimeout: Int = 2500,
                   private val readTimeout:Int = 1500,
Daniel Wolf's avatar
Daniel Wolf committed
53
                   private val cronetEngine: CronetEngine?,
54
                   httpsCronetEngine: CronetEngine? = null,
Daniel Wolf's avatar
Daniel Wolf committed
55
                   val log:(line:String) -> Unit) {
56
    private val httpClient by lazy(LazyThreadSafetyMode.NONE) {
57
58
59
        OkHttpClient.Builder()
            .dns(httpsDnsClient)
            .connectTimeout(3, TimeUnit.SECONDS)
60
            .readTimeout(readTimeout.toLong(), TimeUnit.MILLISECONDS)
61
62
            .build()
    }
63
    private val httpsDnsClient by lazy(LazyThreadSafetyMode.NONE) {
64
65
66
67
        PinnedDns((server as HttpsDnsServerInformation).serverConfigurations.values.map {
            it.urlCreator.address
        })
    }
68
69
70
    private val _httpsCronetEngine by lazy(LazyThreadSafetyMode.NONE) {
        httpsCronetEngine ?: createHttpCronetEngineIfInstalled(context)
    }
71

72
73
74
75
76
77
78
79
80
    companion object {
        val testDomains = listOf("google.com", "frostnerd.com", "amazon.com", "youtube.com", "github.com",
            "stackoverflow.com", "stackexchange.com", "spotify.com", "material.io", "reddit.com", "android.com")
    }

    /**
     * @param passes The amount of requests to make
     * @return The average response time (in ms)
     */
81
    fun runTest(@IntRange(from = 1) passes: Int, strategy: Strategy = Strategy.AVERAGE): Int? {
82
        val latencies = mutableListOf<Int>()
83

84
        var firstPass = true
85
        for (i in 0 until passes) {
Daniel Wolf's avatar
Daniel Wolf committed
86
87
            when(server.type) {
                ServerType.DOT -> {
Daniel Wolf's avatar
Daniel Wolf committed
88
                    @Suppress("UNCHECKED_CAST")
Daniel Wolf's avatar
Daniel Wolf committed
89
90
91
92
                    (server as DnsServerInformation<TLSUpstreamAddress>).servers.forEach {
                        if(firstPass) testTls(it.address)
                        latencies += testTls(it.address) ?: 0
                    }
93
                }
Daniel Wolf's avatar
Daniel Wolf committed
94
95
96
                ServerType.DOH -> {
                    server as HttpsDnsServerInformation
                    server.serverConfigurations.values.forEach {
97
                        latencies += if(_httpsCronetEngine == null) {
98
99
100
101
102
103
104
                            if(firstPass) testHttps(it)
                            testHttps(it) ?: 0
                        } else {
                            if(firstPass) testHttpsCronet(it)
                            testHttpsCronet(it) ?: 0
                        }

Daniel Wolf's avatar
Daniel Wolf committed
105
106
107
                    }
                }
                ServerType.DOQ -> {
Daniel Wolf's avatar
Daniel Wolf committed
108
                    @Suppress("UNCHECKED_CAST")
Daniel Wolf's avatar
Daniel Wolf committed
109
110
                    (server as DnsServerInformation<QuicUpstreamAddress>).servers.forEach {
                        if(cronetEngine != null) {
111
112
                            if(firstPass) testQuic(it.address)
                            latencies += testQuic(it.address) ?: 0
Daniel Wolf's avatar
Daniel Wolf committed
113
114
                        }
                    }
115
116
                }
            }
117
            firstPass = false
118
        }
119
120
121
        if(server.type == ServerType.DOH) {
            if(_httpsCronetEngine == null) httpClient.connectionPool.evictAll()
        }
Daniel Wolf's avatar
Daniel Wolf committed
122
123
124
125
126
127
128
129
130
131
        return when (strategy) {
            Strategy.BEST_CASE -> {
                latencies.minByOrNull {
                    it
                }
            }
            Strategy.AVERAGE -> {
                latencies.sum().let {
                    if(it <= 0) null else it
                }?.div(passes)
132
            }
Daniel Wolf's avatar
Daniel Wolf committed
133
134
135
136
137
138
139
140
141
142
143
            else -> {
                var pos = 0
                latencies.sumBy {
                    // Weight first responses less (min 80%)
                    val minWeight = 90
                    val step = minOf(2, (100-minWeight)/passes)
                    val weight = maxOf(100, minOf(minWeight, 100-(passes - pos++)*step))
                    (it*weight)/100
                }.let {
                    if(it <= 0) null else it
                }
144
            }
145
146
147
148
149
        }
    }

    private fun testHttps(config: ServerConfiguration): Int? {
        val msg = createTestDnsPacket()
150
        val start = System.currentTimeMillis()
151
        val url: URL = config.urlCreator.createUrl(msg, config.urlCreator.address)
152
153
154
155
156
157
158
159
        try {
            url.toString().toHttpUrl()
            log("Using URL: $url")
        } catch (ignored:IllegalArgumentException) {
            log("Invalid URL: $url")
            return null
        }

160
161
162
163
164
        val requestBuilder = Request.Builder().url(url)
        if (config.requestHasBody) {
            val body = config.bodyCreator!!.createBody(msg, config.urlCreator.address)
            if (body != null) {
                requestBuilder.header("Content-Type", config.contentType)
165
                requestBuilder.post(body.rawBody.toRequestBody(body.mediaType?.toMediaTypeOrNull(), 0, body.rawBody.size))
166
            } else {
Daniel Wolf's avatar
Daniel Wolf committed
167
                log("DoH test failed once for ${server.name}: BodyCreator didn't create a body")
168
169
170
                return null
            }
        }
171
        var response:Response? = null
172
        try {
173
            response = httpClient.newCall(requestBuilder.build()).execute()
Daniel Wolf's avatar
Daniel Wolf committed
174
            if(!response.isSuccessful) {
Daniel Wolf's avatar
Daniel Wolf committed
175
                log("DoH test failed once for ${server.name}: Request not successful (${response.code})")
Daniel Wolf's avatar
Daniel Wolf committed
176
177
178
179
180
181
                return null
            }
            val body = response.body ?: run{
                log("DoH test failed once for ${server.name}: No response body")
                return null
            }
182
183
184
185
            val bytes = body.bytes()
            val time = (System.currentTimeMillis() - start).toInt()

            if (bytes.size < 17) {
Daniel Wolf's avatar
Daniel Wolf committed
186
                log("DoH test failed once for ${server.name}: Returned less than 17 bytes")
187
188
                return null
            } else if(!testResponse(DnsMessage(bytes))) {
Daniel Wolf's avatar
Daniel Wolf committed
189
                log("DoH test failed once for ${server.name}: Testing the response for valid dns message failed")
190
191
192
193
                return null
            }
            return time
        } catch (ex: Exception) {
194
            log("DoH test failed with exception once for ${server.name}: ${ex.message}")
195
            return null
196
        } finally {
Daniel Wolf's avatar
Daniel Wolf committed
197
            if(response?.body != null) response.close()
198
199
200
        }
    }

201
202
203
204
205
206
207
208
209
210
211
212
    private fun testHttpsCronet(config: ServerConfiguration): Int? {
        val msg = createTestDnsPacket()
        val start = System.currentTimeMillis()
        val url: URL = config.urlCreator.createUrl(msg, config.urlCreator.address)
        try {
            url.toString().toHttpUrl()
            log("Using URL: $url")
        } catch (ignored:IllegalArgumentException) {
            log("Invalid URL: $url")
            return null
        }

213
214
        var connection:HttpURLConnection? = null
        var wasEstablished = false
215
        try {
216
            connection = _httpsCronetEngine!!.openConnection(url) as HttpURLConnection
217
218
219
220
221
222
223
224
            connection.connectTimeout = connectTimeout
            if(config.requestHasBody) {
                val body = config.bodyCreator?.createBody(msg, config.urlCreator.address) ?: return null
                connection.requestMethod = "POST"
                connection.setRequestProperty("Content-Type", config.contentType)
                connection.doOutput = true
                body.mediaType?.also {connection.setRequestProperty("Accept", it) }
                connection.readTimeout = (connectTimeout*1.5).toInt()
225
                wasEstablished = true
226
227
228
229
230
                val outputStream = connection.outputStream
                outputStream.write(body.rawBody)
                outputStream.flush()
            } else {
                connection.requestMethod = "GET"
231
                wasEstablished = true
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
                connection.connect()
            }
            val status = connection.responseCode
            if(status == 200) {
                val data = connection.inputStream.readBytes().takeIf {
                    it.isNotEmpty()
                } ?: return null
                val time = System.currentTimeMillis() - start
                return if(testResponse(DnsMessage(data))) {
                    time.toInt()
                } else null
            } else {
                connection.inputStream.readBytes()
                return null
            }
        } catch (ex:Throwable) {
248
            log("Request to failed: $ex")
249
            return null
250
251
252
        } finally {
            connection?.disconnect()
            if(wasEstablished) {
253
254
255
256
257
258
                try {
                    connection?.inputStream?.closeSilently()
                    connection?.outputStream?.closeSilently()
                } catch (ex: java.lang.Exception) {
                    log("Could not close streams of failed request: $ex")
                }
259
            }
260
261
262
        }
    }

263
264
    private fun testTls(address: TLSUpstreamAddress): Int? {
        val addr =
Daniel Wolf's avatar
Daniel Wolf committed
265
            address.addressCreator.resolveOrGetResultOrNull(retryIfError = true, runResolveNow = true) ?: run {
Daniel Wolf's avatar
Daniel Wolf committed
266
267
268
                log("DoT test failed once for ${server.name}: Address failed to resolve ($address)")
                return null
            }
269
        var socket:Socket? = null
270
        var outputStream:DataOutputStream? = null
271
        try {
272
            socket = SSLSocketFactory.getDefault().createSocket()
273
274
            val msg = createTestDnsPacket()
            val start = System.currentTimeMillis()
275
            socket!!.connect(InetSocketAddress(addr[0], address.port), connectTimeout)
276
            socket.soTimeout = readTimeout
277
            val data: ByteArray = msg.toArray()
278
            outputStream = DataOutputStream(socket.getOutputStream())
279
280
281
282
283
284
285
286
287
288
289
            val size = data.size
            val arr: ByteArray = byteArrayOf(((size shr 8) and 0xFF).toByte(), (size and 0xFF).toByte())
            outputStream.write(arr)
            outputStream.write(data)
            outputStream.flush()

            val inStream = DataInputStream(socket.getInputStream())
            val readData = ByteArray(inStream.readUnsignedShort())
            inStream.read(readData)
            val time = (System.currentTimeMillis() - start).toInt()

Daniel Wolf's avatar
Daniel Wolf committed
290
291
292
293
            if(!testResponse(DnsMessage(readData))) {
                log("DoT test failed once for ${server.name}: Testing the response for valid dns message failed")
                return null
            }
294
295
            return time
        } catch (ex: Exception) {
Daniel Wolf's avatar
Daniel Wolf committed
296
            log("DoT test failed with exception once for ${server.name}: $ex")
297
298
299
300
301
302
            return null
        } finally {
            socket?.close()
        }
    }

Daniel Wolf's avatar
Daniel Wolf committed
303
304
    private fun testQuic(address: QuicUpstreamAddress):Int? {
        val url = URL(address.getUrl(false))
Daniel Wolf's avatar
Daniel Wolf committed
305
306
307
308
309
        var connection: HttpURLConnection? = null
        var wasEstablished = false
        val msg = createTestDnsPacket()
        try {
            val start = System.currentTimeMillis()
310
            connection = cronetEngine!!.openConnection(url) as HttpURLConnection
Daniel Wolf's avatar
Daniel Wolf committed
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
            connection.connectTimeout = connectTimeout
            connection.requestMethod = "POST"
            connection.setRequestProperty("Content-Type", "application/dns-message")
            connection.setRequestProperty("Accept", "application/dns-message")
            connection.doOutput = true
            connection.readTimeout = readTimeout
            wasEstablished = true
            val outputStream = connection.outputStream
            outputStream.write(msg.toArray())
            outputStream.flush()
            val status = connection.responseCode
            if(status == 200) {
                val data = connection.inputStream.readBytes().takeIf {
                    it.isNotEmpty()
                } ?: return null
                val time = (System.currentTimeMillis() - start).toInt()
                if(!testResponse(DnsMessage(data))) {
                    log("DoT test failed once for ${server.name}: Testing the response for valid dns message failed")
                    return null
                }
                return time
            } else {
               return null
            }
        } catch (ex: java.lang.Exception) {
            return null
        } finally {
338
339
340
            connection?.disconnect()
            if(wasEstablished) {
                connection?.outputStream?.closeSilently()
Daniel Wolf's avatar
Daniel Wolf committed
341
                connection?.inputStream?.closeSilently()
342
            }
Daniel Wolf's avatar
Daniel Wolf committed
343
344
345
        }
    }

346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
    private fun createTestDnsPacket(): DnsMessage {
        val msg = DnsMessage.builder().setQrFlag(false)
            .addQuestion(Question(testDomains.random(), Record.TYPE.A, Record.CLASS.IN))
            .setId(Random.nextInt(1, 999999))
            .setRecursionDesired(true)
            .setAuthenticData(true)
            .setRecursionAvailable(true)
        return msg.build()
    }

    private fun testResponse(message:DnsMessage):Boolean {
        return message.answerSection.size > 0
    }

    private inner class PinnedDns(private val upstreamServers: List<UpstreamAddress>) : Dns {

        override fun lookup(hostname: String): MutableList<InetAddress> {
            val res = mutableListOf<InetAddress>()
            for (server in upstreamServers) {
                if (server.host.equals(hostname, true)) {
                    res.addAll(server.addressCreator.resolveOrGetResultOrNull(true) ?: emptyArray())
                    break
                }
            }
            if (res.isEmpty()) {
                res.addAll(Dns.SYSTEM.lookup(hostname))
            }
            if (res.isEmpty()) {
                throw UnknownHostException("Could not resolve $hostname")
            }
            return res
        }
    }
379
380

    enum class Strategy {
Daniel Wolf's avatar
Daniel Wolf committed
381
382
383
        AVERAGE, BEST_CASE,
        @Suppress("unused")
        WEIGHTED_AVERAGE
384
    }
385
}