@onekeyfe/react-native-sni-connect 3.0.71 → 3.0.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -1
- package/SniConnect.podspec +2 -2
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/sniconnect/SniConnectLogger.kt +32 -1
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +508 -68
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +259 -13
- package/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +257 -0
- package/ios/SniConnect.mm +15 -0
- package/ios/SniConnect.swift +119 -22
- package/ios/SniConnectClient.swift +511 -174
- package/ios/SniConnectCore.swift +228 -0
- package/ios/SniConnectLog.swift +39 -0
- package/ios/SniConnectValidation.swift +243 -1
- package/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +325 -0
- package/lib/module/index.js +3 -0
- package/lib/typescript/src/NativeSniConnect.d.ts +25 -4
- package/lib/typescript/src/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/NativeSniConnect.ts +35 -4
- package/src/index.tsx +12 -1
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
package com.sniconnect
|
|
2
2
|
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.net.ConnectivityManager
|
|
3
5
|
import com.facebook.react.bridge.Arguments
|
|
4
6
|
import com.facebook.react.bridge.Promise
|
|
5
7
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
@@ -15,20 +17,59 @@ import okhttp3.Dns
|
|
|
15
17
|
import okhttp3.Headers
|
|
16
18
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
17
19
|
import okhttp3.OkHttpClient
|
|
20
|
+
import okhttp3.Protocol
|
|
18
21
|
import okhttp3.Request
|
|
19
22
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
20
23
|
import okhttp3.Response
|
|
21
24
|
import okhttp3.ResponseBody
|
|
25
|
+
import okio.Buffer
|
|
22
26
|
import java.io.IOException
|
|
27
|
+
import java.io.InterruptedIOException
|
|
23
28
|
import java.net.InetAddress
|
|
29
|
+
import java.net.Proxy
|
|
30
|
+
import java.net.ProxySelector
|
|
31
|
+
import java.net.URI
|
|
32
|
+
import java.net.UnknownHostException
|
|
33
|
+
import java.nio.charset.StandardCharsets
|
|
34
|
+
import java.security.cert.CertificateException
|
|
35
|
+
import java.util.Collections
|
|
24
36
|
import java.util.Locale
|
|
25
37
|
import java.util.concurrent.ConcurrentHashMap
|
|
26
38
|
import java.util.concurrent.TimeUnit
|
|
27
39
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
28
40
|
import javax.net.ssl.HttpsURLConnection
|
|
41
|
+
import javax.net.ssl.SSLException
|
|
42
|
+
import javax.net.ssl.SSLPeerUnverifiedException
|
|
29
43
|
|
|
30
44
|
private const val TAG = "SniConnect"
|
|
31
45
|
|
|
46
|
+
internal fun classifySniFailureCode(error: Throwable): String {
|
|
47
|
+
if (hasCause(error, SSLPeerUnverifiedException::class.java) ||
|
|
48
|
+
hasCause(error, CertificateException::class.java)
|
|
49
|
+
) {
|
|
50
|
+
return "SNI_CERT_FAILED"
|
|
51
|
+
}
|
|
52
|
+
if (hasCause(error, UnknownHostException::class.java)) {
|
|
53
|
+
return "SNI_SECURITY_POLICY_FAILED"
|
|
54
|
+
}
|
|
55
|
+
if (hasCause(error, InterruptedIOException::class.java)) {
|
|
56
|
+
return "SNI_REQUEST_TIMEOUT"
|
|
57
|
+
}
|
|
58
|
+
if (hasCause(error, SSLException::class.java)) {
|
|
59
|
+
return "SNI_TLS_FAILED"
|
|
60
|
+
}
|
|
61
|
+
return "SNI_REQUEST_FAILED"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private fun hasCause(error: Throwable, type: Class<out Throwable>): Boolean {
|
|
65
|
+
var current: Throwable? = error
|
|
66
|
+
while (current != null) {
|
|
67
|
+
if (type.isInstance(current)) return true
|
|
68
|
+
current = current.cause
|
|
69
|
+
}
|
|
70
|
+
return false
|
|
71
|
+
}
|
|
72
|
+
|
|
32
73
|
@ReactModule(name = SniConnectModule.NAME)
|
|
33
74
|
class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
34
75
|
NativeSniConnectSpec(reactContext) {
|
|
@@ -42,7 +83,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
42
83
|
|
|
43
84
|
// A single dispatcher + connection pool shared across all cached clients so we
|
|
44
85
|
// don't spawn a thread pool / connection pool per (hostname, ip) pair.
|
|
45
|
-
private val sharedDispatcher = Dispatcher()
|
|
86
|
+
private val sharedDispatcher = Dispatcher().apply {
|
|
87
|
+
maxRequests = 64
|
|
88
|
+
maxRequestsPerHost = 64
|
|
89
|
+
}
|
|
46
90
|
private val sharedConnectionPool = ConnectionPool()
|
|
47
91
|
}
|
|
48
92
|
|
|
@@ -63,11 +107,27 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
63
107
|
// LinkedHashMap is not thread-safe.
|
|
64
108
|
private val clientCache = object : LinkedHashMap<ClientKey, OkHttpClient>(16, 0.75f, true) {
|
|
65
109
|
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ClientKey, OkHttpClient>): Boolean {
|
|
66
|
-
|
|
110
|
+
val shouldEvict = size > MAX_CLIENTS
|
|
111
|
+
if (shouldEvict) {
|
|
112
|
+
SniConnectLogger.info(
|
|
113
|
+
SniConnectLogger.event(
|
|
114
|
+
"sni_cache_evict",
|
|
115
|
+
"hostname" to eldest.key.hostname,
|
|
116
|
+
"ipHash" to SniConnectLogger.shortHash(eldest.key.ip),
|
|
117
|
+
"cacheSize" to size,
|
|
118
|
+
"limit" to MAX_CLIENTS,
|
|
119
|
+
"reason" to "max_cached_clients",
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
}
|
|
123
|
+
return shouldEvict
|
|
67
124
|
}
|
|
68
125
|
}
|
|
69
126
|
|
|
70
127
|
private val activeCalls = ConcurrentHashMap<String, Call>()
|
|
128
|
+
private val allActiveCalls = Collections.newSetFromMap(ConcurrentHashMap<Call, Boolean>())
|
|
129
|
+
private val activeCallsLock = Any()
|
|
130
|
+
private val requestLimiter = SniConnectRequestLimiter()
|
|
71
131
|
|
|
72
132
|
override fun getName(): String = NAME
|
|
73
133
|
|
|
@@ -76,29 +136,62 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
76
136
|
val requestConfig = config.toRequestConfig()
|
|
77
137
|
performRequest(requestConfig, promise)
|
|
78
138
|
} catch (error: Exception) {
|
|
79
|
-
SniConnectLogger.error(
|
|
139
|
+
SniConnectLogger.error(
|
|
140
|
+
SniConnectLogger.event(
|
|
141
|
+
"sni_request_result",
|
|
142
|
+
"result" to "error",
|
|
143
|
+
"code" to "SNI_INVALID_CONFIG",
|
|
144
|
+
"nativeErrorClass" to error.javaClass.simpleName,
|
|
145
|
+
"stage" to "parse_config",
|
|
146
|
+
),
|
|
147
|
+
)
|
|
80
148
|
promise.reject("SNI_INVALID_CONFIG", error.message, error)
|
|
81
149
|
}
|
|
82
150
|
}
|
|
83
151
|
|
|
84
152
|
@ReactMethod
|
|
85
153
|
override fun cancelRequest(requestId: String, promise: Promise) {
|
|
86
|
-
val call =
|
|
154
|
+
val call = synchronized(activeCallsLock) {
|
|
155
|
+
activeCalls.remove(requestId)
|
|
156
|
+
}
|
|
87
157
|
if (call != null) {
|
|
88
158
|
call.cancel()
|
|
89
|
-
SniConnectLogger.info(
|
|
159
|
+
SniConnectLogger.info(
|
|
160
|
+
SniConnectLogger.event(
|
|
161
|
+
"sni_cancel",
|
|
162
|
+
"requestIdHash" to SniConnectLogger.shortHash(requestId),
|
|
163
|
+
"success" to true,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
90
166
|
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
91
167
|
} else {
|
|
168
|
+
SniConnectLogger.warn(
|
|
169
|
+
SniConnectLogger.event(
|
|
170
|
+
"sni_cancel",
|
|
171
|
+
"requestIdHash" to SniConnectLogger.shortHash(requestId),
|
|
172
|
+
"success" to false,
|
|
173
|
+
),
|
|
174
|
+
)
|
|
92
175
|
promise.resolve(Arguments.createMap().apply { putBoolean("success", false) })
|
|
93
176
|
}
|
|
94
177
|
}
|
|
95
178
|
|
|
96
179
|
@ReactMethod
|
|
97
180
|
override fun cancelAllRequests(promise: Promise) {
|
|
98
|
-
val
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
181
|
+
val calls = synchronized(activeCallsLock) {
|
|
182
|
+
val snapshot = allActiveCalls.toList()
|
|
183
|
+
activeCalls.clear()
|
|
184
|
+
allActiveCalls.clear()
|
|
185
|
+
snapshot
|
|
186
|
+
}
|
|
187
|
+
calls.forEach { call -> call.cancel() }
|
|
188
|
+
SniConnectLogger.info(
|
|
189
|
+
SniConnectLogger.event(
|
|
190
|
+
"sni_cancel_all",
|
|
191
|
+
"cancelledCount" to calls.size,
|
|
192
|
+
"success" to true,
|
|
193
|
+
),
|
|
194
|
+
)
|
|
102
195
|
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
103
196
|
}
|
|
104
197
|
|
|
@@ -109,12 +202,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
109
202
|
}
|
|
110
203
|
// Drop pinned-IP connections from the shared pool.
|
|
111
204
|
sharedConnectionPool.evictAll()
|
|
112
|
-
SniConnectLogger.info(
|
|
205
|
+
SniConnectLogger.info(
|
|
206
|
+
SniConnectLogger.event(
|
|
207
|
+
"sni_lifecycle",
|
|
208
|
+
"action" to "clear_dns_cache",
|
|
209
|
+
"success" to true,
|
|
210
|
+
),
|
|
211
|
+
)
|
|
113
212
|
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
114
213
|
}
|
|
115
214
|
|
|
215
|
+
@ReactMethod
|
|
216
|
+
override fun isProxyActiveForUrl(url: String, promise: Promise) {
|
|
217
|
+
try {
|
|
218
|
+
promise.resolve(isProxyActiveForUrl(url))
|
|
219
|
+
} catch (error: Exception) {
|
|
220
|
+
promise.reject("SNI_INVALID_URL", error.message, error)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
116
224
|
private fun performRequest(config: RequestConfig, promise: Promise) {
|
|
225
|
+
val startedAtMs = android.os.SystemClock.elapsedRealtime()
|
|
226
|
+
var requestSlot: SniConnectRequestLimiter.Token? = null
|
|
227
|
+
var registeredCall: Call? = null
|
|
117
228
|
try {
|
|
229
|
+
requestSlot = requestLimiter.acquire(config.hostname, config.ip)
|
|
118
230
|
val client = getOrCreateClient(config)
|
|
119
231
|
val request = buildRequest(config)
|
|
120
232
|
val call = client.newCall(request)
|
|
@@ -122,59 +234,167 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
122
234
|
// Apply per-request timeout
|
|
123
235
|
call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
|
|
124
236
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
237
|
+
registerCall(config.requestId, call)
|
|
238
|
+
registeredCall = call
|
|
239
|
+
|
|
240
|
+
SniConnectLogger.info(
|
|
241
|
+
SniConnectLogger.event(
|
|
242
|
+
"sni_request_start",
|
|
243
|
+
"requestIdHash" to SniConnectLogger.shortHash(config.requestId),
|
|
244
|
+
"hostname" to config.hostname.lowercase(Locale.US),
|
|
245
|
+
"ipHash" to SniConnectLogger.shortHash(config.ip),
|
|
246
|
+
"ipFamily" to SniConnectLogger.ipFamily(config.ip),
|
|
247
|
+
"method" to config.method,
|
|
248
|
+
"timeoutMs" to config.timeoutMillis,
|
|
249
|
+
"headerCount" to config.headers.size,
|
|
250
|
+
"bodyBytes" to (config.body?.toByteArray(StandardCharsets.UTF_8)?.size ?: 0),
|
|
251
|
+
),
|
|
252
|
+
)
|
|
129
253
|
|
|
130
254
|
// Guard against double-settling the promise (RN hard-crashes otherwise).
|
|
131
255
|
val settled = AtomicBoolean(false)
|
|
132
256
|
|
|
133
257
|
call.enqueue(object : Callback {
|
|
134
258
|
override fun onFailure(call: Call, e: IOException) {
|
|
135
|
-
config.requestId
|
|
259
|
+
unregisterCall(config.requestId, call)
|
|
260
|
+
requestSlot?.release()
|
|
136
261
|
if (!settled.compareAndSet(false, true)) return
|
|
137
262
|
|
|
138
263
|
if (call.isCanceled()) {
|
|
139
264
|
promise.reject("SNI_CANCELLED", "Request cancelled", null)
|
|
140
265
|
} else {
|
|
141
|
-
|
|
142
|
-
|
|
266
|
+
val code = classifySniFailureCode(e)
|
|
267
|
+
SniConnectLogger.error(
|
|
268
|
+
SniConnectLogger.event(
|
|
269
|
+
"sni_request_result",
|
|
270
|
+
"result" to "error",
|
|
271
|
+
"code" to code,
|
|
272
|
+
"nativeErrorClass" to e.javaClass.simpleName,
|
|
273
|
+
"requestIdHash" to SniConnectLogger.shortHash(config.requestId),
|
|
274
|
+
"hostname" to config.hostname.lowercase(Locale.US),
|
|
275
|
+
"ipHash" to SniConnectLogger.shortHash(config.ip),
|
|
276
|
+
"ipFamily" to SniConnectLogger.ipFamily(config.ip),
|
|
277
|
+
"method" to config.method,
|
|
278
|
+
"timeoutMs" to config.timeoutMillis,
|
|
279
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
promise.reject(code, e.message, e)
|
|
143
283
|
}
|
|
144
284
|
}
|
|
145
285
|
|
|
146
286
|
override fun onResponse(call: Call, response: Response) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
val
|
|
152
|
-
|
|
287
|
+
try {
|
|
288
|
+
response.use { currentResponse ->
|
|
289
|
+
val bodyString = currentResponse.body.safeString()
|
|
290
|
+
val headerMaps = headersToMaps(currentResponse.headers)
|
|
291
|
+
val resultLog = SniConnectLogger.event(
|
|
292
|
+
"sni_request_result",
|
|
293
|
+
"result" to "response",
|
|
294
|
+
"status" to currentResponse.code,
|
|
295
|
+
"requestIdHash" to SniConnectLogger.shortHash(config.requestId),
|
|
296
|
+
"hostname" to config.hostname.lowercase(Locale.US),
|
|
297
|
+
"ipHash" to SniConnectLogger.shortHash(config.ip),
|
|
298
|
+
"ipFamily" to SniConnectLogger.ipFamily(config.ip),
|
|
299
|
+
"method" to config.method,
|
|
300
|
+
"timeoutMs" to config.timeoutMillis,
|
|
301
|
+
"responseBytes" to bodyString.toByteArray(StandardCharsets.UTF_8).size,
|
|
302
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
303
|
+
)
|
|
153
304
|
Arguments.createMap().apply {
|
|
154
305
|
putString("data", bodyString)
|
|
155
|
-
putInt("status",
|
|
156
|
-
putString("statusText",
|
|
157
|
-
putMap("headers",
|
|
306
|
+
putInt("status", currentResponse.code)
|
|
307
|
+
putString("statusText", currentResponse.message)
|
|
308
|
+
putMap("headers", headerMaps.singleValueHeaders.toWritableMap())
|
|
309
|
+
putMap("multiValueHeaders", headerMaps.multiValueHeaders.toWritableArrayMap())
|
|
310
|
+
}.also { result ->
|
|
311
|
+
if (currentResponse.code >= 400) {
|
|
312
|
+
SniConnectLogger.warn(resultLog)
|
|
313
|
+
} else {
|
|
314
|
+
SniConnectLogger.info(resultLog)
|
|
315
|
+
}
|
|
316
|
+
if (settled.compareAndSet(false, true)) {
|
|
317
|
+
promise.resolve(result)
|
|
318
|
+
}
|
|
158
319
|
}
|
|
159
320
|
}
|
|
160
321
|
} catch (error: Exception) {
|
|
161
322
|
if (!settled.compareAndSet(false, true)) return
|
|
162
|
-
SniConnectLogger.error(
|
|
323
|
+
SniConnectLogger.error(
|
|
324
|
+
SniConnectLogger.event(
|
|
325
|
+
"sni_request_result",
|
|
326
|
+
"result" to "error",
|
|
327
|
+
"code" to "SNI_RESPONSE_FAILED",
|
|
328
|
+
"nativeErrorClass" to error.javaClass.simpleName,
|
|
329
|
+
"requestIdHash" to SniConnectLogger.shortHash(config.requestId),
|
|
330
|
+
"hostname" to config.hostname.lowercase(Locale.US),
|
|
331
|
+
"ipHash" to SniConnectLogger.shortHash(config.ip),
|
|
332
|
+
"ipFamily" to SniConnectLogger.ipFamily(config.ip),
|
|
333
|
+
"method" to config.method,
|
|
334
|
+
"timeoutMs" to config.timeoutMillis,
|
|
335
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
336
|
+
),
|
|
337
|
+
)
|
|
163
338
|
promise.reject("SNI_RESPONSE_FAILED", error.message, error)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
if (response.code >= 400) {
|
|
168
|
-
SniConnectLogger.warn("HTTP ${response.code} for ${config.hostname}")
|
|
169
|
-
}
|
|
170
|
-
if (settled.compareAndSet(false, true)) {
|
|
171
|
-
promise.resolve(result)
|
|
339
|
+
} finally {
|
|
340
|
+
unregisterCall(config.requestId, call)
|
|
341
|
+
requestSlot?.release()
|
|
172
342
|
}
|
|
173
343
|
}
|
|
174
344
|
})
|
|
175
345
|
} catch (error: Exception) {
|
|
176
|
-
|
|
177
|
-
|
|
346
|
+
registeredCall?.let { call ->
|
|
347
|
+
unregisterCall(config.requestId, call)
|
|
348
|
+
}
|
|
349
|
+
requestSlot?.release()
|
|
350
|
+
val code = if (error is SniConnectValidation.ValidationException) {
|
|
351
|
+
"SNI_RESOURCE_LIMIT"
|
|
352
|
+
} else {
|
|
353
|
+
"SNI_REQUEST_FAILED"
|
|
354
|
+
}
|
|
355
|
+
SniConnectLogger.error(
|
|
356
|
+
SniConnectLogger.event(
|
|
357
|
+
"sni_request_result",
|
|
358
|
+
"result" to "error",
|
|
359
|
+
"code" to code,
|
|
360
|
+
"nativeErrorClass" to error.javaClass.simpleName,
|
|
361
|
+
"requestIdHash" to SniConnectLogger.shortHash(config.requestId),
|
|
362
|
+
"hostname" to config.hostname.lowercase(Locale.US),
|
|
363
|
+
"ipHash" to SniConnectLogger.shortHash(config.ip),
|
|
364
|
+
"ipFamily" to SniConnectLogger.ipFamily(config.ip),
|
|
365
|
+
"method" to config.method,
|
|
366
|
+
"timeoutMs" to config.timeoutMillis,
|
|
367
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
368
|
+
),
|
|
369
|
+
)
|
|
370
|
+
promise.reject(code, error.message, error)
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
private fun registerCall(requestId: String?, call: Call) {
|
|
375
|
+
val previousCall = synchronized(activeCallsLock) {
|
|
376
|
+
val previous = requestId?.let { activeCalls.put(it, call) }
|
|
377
|
+
allActiveCalls.add(call)
|
|
378
|
+
previous
|
|
379
|
+
}
|
|
380
|
+
if (previousCall != null && previousCall != call) {
|
|
381
|
+
previousCall.cancel()
|
|
382
|
+
SniConnectLogger.warn(
|
|
383
|
+
SniConnectLogger.event(
|
|
384
|
+
"sni_duplicate_request_id",
|
|
385
|
+
"requestIdHash" to SniConnectLogger.shortHash(requestId),
|
|
386
|
+
"action" to "cancel_previous",
|
|
387
|
+
),
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
private fun unregisterCall(requestId: String?, call: Call) {
|
|
393
|
+
synchronized(activeCallsLock) {
|
|
394
|
+
if (requestId != null) {
|
|
395
|
+
activeCalls.remove(requestId, call)
|
|
396
|
+
}
|
|
397
|
+
allActiveCalls.remove(call)
|
|
178
398
|
}
|
|
179
399
|
}
|
|
180
400
|
|
|
@@ -185,16 +405,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
185
405
|
synchronized(clientCache) {
|
|
186
406
|
clientCache[key]?.let { return it }
|
|
187
407
|
|
|
188
|
-
// 60s defaults at the client level; the real deadline is the per-call timeout.
|
|
189
|
-
val defaultTimeout = 60_000L
|
|
190
|
-
|
|
191
408
|
val client = OkHttpClient.Builder()
|
|
192
409
|
.dispatcher(sharedDispatcher)
|
|
193
410
|
.connectionPool(sharedConnectionPool)
|
|
194
|
-
.
|
|
195
|
-
.
|
|
196
|
-
.
|
|
411
|
+
.proxy(Proxy.NO_PROXY)
|
|
412
|
+
.protocols(listOf(Protocol.HTTP_1_1))
|
|
413
|
+
.connectTimeout(0, TimeUnit.MILLISECONDS)
|
|
414
|
+
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
415
|
+
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
|
197
416
|
.callTimeout(0, TimeUnit.MILLISECONDS)
|
|
417
|
+
.followRedirects(false)
|
|
418
|
+
.followSslRedirects(false)
|
|
198
419
|
// TLS is validated normally: cert chain via the default trust manager and
|
|
199
420
|
// hostname verification against the REAL hostname (not the pinned IP).
|
|
200
421
|
.hostnameVerifier { _, session ->
|
|
@@ -204,6 +425,19 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
204
425
|
.build()
|
|
205
426
|
|
|
206
427
|
clientCache[key] = client
|
|
428
|
+
SniConnectLogger.info(
|
|
429
|
+
SniConnectLogger.event(
|
|
430
|
+
"sni_transport_config",
|
|
431
|
+
"hostname" to key.hostname,
|
|
432
|
+
"ipHash" to SniConnectLogger.shortHash(key.ip),
|
|
433
|
+
"ipFamily" to SniConnectLogger.ipFamily(key.ip),
|
|
434
|
+
"proxyMode" to "no_proxy",
|
|
435
|
+
"pinnedResolver" to true,
|
|
436
|
+
"protocol" to "http1",
|
|
437
|
+
"followRedirects" to false,
|
|
438
|
+
"cacheSize" to clientCache.size,
|
|
439
|
+
),
|
|
440
|
+
)
|
|
207
441
|
return client
|
|
208
442
|
}
|
|
209
443
|
}
|
|
@@ -218,7 +452,15 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
218
452
|
return if (requestedHost.lowercase(Locale.US) == expectedHost) {
|
|
219
453
|
listOf(pinnedAddress)
|
|
220
454
|
} else {
|
|
221
|
-
|
|
455
|
+
SniConnectLogger.warn(
|
|
456
|
+
SniConnectLogger.event(
|
|
457
|
+
"sni_pinned_dns_unexpected_host",
|
|
458
|
+
"expectedHost" to expectedHost,
|
|
459
|
+
"requestedHostHash" to SniConnectLogger.shortHash(requestedHost.lowercase(Locale.US)),
|
|
460
|
+
"result" to "fail_closed",
|
|
461
|
+
),
|
|
462
|
+
)
|
|
463
|
+
throw UnknownHostException("Unexpected host for pinned SNI request: $requestedHost")
|
|
222
464
|
}
|
|
223
465
|
}
|
|
224
466
|
}
|
|
@@ -232,30 +474,35 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
232
474
|
val builder = Request.Builder().url(url)
|
|
233
475
|
|
|
234
476
|
config.headers.forEach { (key, value) ->
|
|
235
|
-
|
|
236
|
-
builder.addHeader(key, value)
|
|
237
|
-
}
|
|
477
|
+
builder.addHeader(key, value)
|
|
238
478
|
}
|
|
239
479
|
builder.header("Host", config.hostname)
|
|
480
|
+
builder.header("Accept-Encoding", "identity")
|
|
240
481
|
|
|
241
482
|
val method = config.method
|
|
242
|
-
val
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
483
|
+
val requestBody = config.body?.let { bodyContent ->
|
|
484
|
+
val mediaType = config.headers.entries
|
|
485
|
+
.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
|
|
486
|
+
?.value
|
|
487
|
+
?.toMediaTypeOrNull()
|
|
488
|
+
bodyContent.toRequestBody(mediaType)
|
|
489
|
+
}
|
|
248
490
|
|
|
249
491
|
when (method) {
|
|
250
492
|
"GET" -> builder.get()
|
|
251
493
|
"HEAD" -> builder.head()
|
|
494
|
+
"DELETE" -> {
|
|
495
|
+
if (requestBody == null) {
|
|
496
|
+
builder.delete()
|
|
497
|
+
} else {
|
|
498
|
+
builder.delete(requestBody)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
252
501
|
else -> {
|
|
253
|
-
val requestBody = bodyContent.toRequestBody(mediaType)
|
|
254
502
|
when (method) {
|
|
255
|
-
"POST" -> builder.post(requestBody)
|
|
256
|
-
"PUT" -> builder.put(requestBody)
|
|
257
|
-
"PATCH" -> builder.patch(requestBody)
|
|
258
|
-
"DELETE" -> builder.delete(requestBody)
|
|
503
|
+
"POST" -> builder.post(requireNotNull(requestBody))
|
|
504
|
+
"PUT" -> builder.put(requireNotNull(requestBody))
|
|
505
|
+
"PATCH" -> builder.patch(requireNotNull(requestBody))
|
|
259
506
|
else -> builder.method(method, requestBody)
|
|
260
507
|
}
|
|
261
508
|
}
|
|
@@ -267,18 +514,36 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
267
514
|
private fun ResponseBody?.safeString(): String {
|
|
268
515
|
if (this == null) return ""
|
|
269
516
|
return try {
|
|
270
|
-
|
|
517
|
+
val source = source()
|
|
518
|
+
val buffer = Buffer()
|
|
519
|
+
var totalBytes = 0L
|
|
520
|
+
while (true) {
|
|
521
|
+
val read = source.read(buffer, 8 * 1024)
|
|
522
|
+
if (read == -1L) break
|
|
523
|
+
totalBytes += read
|
|
524
|
+
if (totalBytes > SniConnectValidation.MAX_RESPONSE_BODY_BYTES) {
|
|
525
|
+
throw IOException("Response body too large")
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
val charset = contentType()?.charset(StandardCharsets.UTF_8) ?: StandardCharsets.UTF_8
|
|
529
|
+
buffer.readString(charset)
|
|
271
530
|
} catch (error: IOException) {
|
|
272
531
|
throw IOException("Failed to read response body", error)
|
|
273
532
|
}
|
|
274
533
|
}
|
|
275
534
|
|
|
276
|
-
private fun
|
|
277
|
-
val
|
|
278
|
-
|
|
279
|
-
|
|
535
|
+
private fun headersToMaps(headers: Headers): HeaderMaps {
|
|
536
|
+
val singleValueHeaders = linkedMapOf<String, String>()
|
|
537
|
+
val multiValueHeaders = linkedMapOf<String, MutableList<String>>()
|
|
538
|
+
|
|
539
|
+
for (index in 0 until headers.size) {
|
|
540
|
+
val name = headers.name(index).lowercase(Locale.US)
|
|
541
|
+
val value = headers.value(index)
|
|
542
|
+
singleValueHeaders[name] = value
|
|
543
|
+
multiValueHeaders.getOrPut(name) { mutableListOf() }.add(value)
|
|
280
544
|
}
|
|
281
|
-
|
|
545
|
+
|
|
546
|
+
return HeaderMaps(singleValueHeaders, multiValueHeaders)
|
|
282
547
|
}
|
|
283
548
|
|
|
284
549
|
private fun Map<String, String>.toWritableMap(): WritableMap {
|
|
@@ -287,8 +552,18 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
287
552
|
}
|
|
288
553
|
}
|
|
289
554
|
|
|
555
|
+
private fun Map<String, List<String>>.toWritableArrayMap(): WritableMap {
|
|
556
|
+
return Arguments.createMap().apply {
|
|
557
|
+
forEach { (key, values) ->
|
|
558
|
+
val array = Arguments.createArray()
|
|
559
|
+
values.forEach { value -> array.pushString(value) }
|
|
560
|
+
putArray(key, array)
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
290
565
|
private fun ReadableMap.toRequestConfig(): RequestConfig {
|
|
291
|
-
val
|
|
566
|
+
val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
|
|
292
567
|
getMap("headers")?.toHashMap()
|
|
293
568
|
?.mapValues { (_, value) -> value?.toString() ?: "" }
|
|
294
569
|
?: emptyMap()
|
|
@@ -297,7 +572,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
297
572
|
}
|
|
298
573
|
|
|
299
574
|
val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
|
|
300
|
-
getDouble("timeout")
|
|
575
|
+
SniConnectValidation.parseTimeoutMillis(getDouble("timeout"))
|
|
301
576
|
} else {
|
|
302
577
|
30_000L
|
|
303
578
|
}
|
|
@@ -308,13 +583,18 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
308
583
|
val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
|
|
309
584
|
val method = getString("method") ?: "GET"
|
|
310
585
|
val path = getString("path") ?: "/"
|
|
586
|
+
val body = if (hasKey("body") && !isNull("body")) getString("body") else null
|
|
311
587
|
|
|
312
588
|
// Validate every caller-controlled field at the boundary.
|
|
589
|
+
SniConnectValidation.validateRequestId(requestId)
|
|
313
590
|
SniConnectValidation.validatePublicIp(ip)
|
|
314
591
|
SniConnectValidation.validateHostname(hostname)
|
|
315
|
-
SniConnectValidation.
|
|
592
|
+
val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
|
|
316
593
|
val normalizedMethod = SniConnectValidation.normalizeMethod(method)
|
|
317
594
|
val normalizedPath = SniConnectValidation.normalizePath(path)
|
|
595
|
+
SniConnectValidation.validateTimeout(timeoutMillis)
|
|
596
|
+
SniConnectValidation.validateBody(body)
|
|
597
|
+
SniConnectValidation.validateMethodBody(normalizedMethod, body)
|
|
318
598
|
|
|
319
599
|
return RequestConfig(
|
|
320
600
|
requestId = requestId,
|
|
@@ -323,7 +603,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
323
603
|
method = normalizedMethod,
|
|
324
604
|
path = normalizedPath,
|
|
325
605
|
headers = headersMap,
|
|
326
|
-
body =
|
|
606
|
+
body = body,
|
|
327
607
|
timeoutMillis = timeoutMillis,
|
|
328
608
|
)
|
|
329
609
|
}
|
|
@@ -338,4 +618,164 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
338
618
|
val body: String?,
|
|
339
619
|
val timeoutMillis: Long,
|
|
340
620
|
)
|
|
621
|
+
|
|
622
|
+
private data class HeaderMaps(
|
|
623
|
+
val singleValueHeaders: Map<String, String>,
|
|
624
|
+
val multiValueHeaders: Map<String, List<String>>,
|
|
625
|
+
)
|
|
626
|
+
|
|
627
|
+
private fun isProxyActiveForUrl(url: String): Boolean {
|
|
628
|
+
val startedAtMs = android.os.SystemClock.elapsedRealtime()
|
|
629
|
+
val uri = try {
|
|
630
|
+
URI(url)
|
|
631
|
+
} catch (error: Exception) {
|
|
632
|
+
SniConnectLogger.warn(
|
|
633
|
+
SniConnectLogger.event(
|
|
634
|
+
"proxy_preflight",
|
|
635
|
+
"platform" to "android",
|
|
636
|
+
"scheme" to "unknown",
|
|
637
|
+
"host" to "unknown",
|
|
638
|
+
"result" to "invalid_url",
|
|
639
|
+
"source" to "validation",
|
|
640
|
+
"proxyTypeCount" to 0,
|
|
641
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
642
|
+
),
|
|
643
|
+
)
|
|
644
|
+
throw error
|
|
645
|
+
}
|
|
646
|
+
val scheme = uri.scheme?.lowercase(Locale.US)
|
|
647
|
+
?: run {
|
|
648
|
+
SniConnectLogger.warn(
|
|
649
|
+
SniConnectLogger.event(
|
|
650
|
+
"proxy_preflight",
|
|
651
|
+
"platform" to "android",
|
|
652
|
+
"scheme" to "unknown",
|
|
653
|
+
"host" to "unknown",
|
|
654
|
+
"result" to "invalid_url",
|
|
655
|
+
"source" to "validation",
|
|
656
|
+
"proxyTypeCount" to 0,
|
|
657
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
658
|
+
),
|
|
659
|
+
)
|
|
660
|
+
throw IllegalArgumentException("URL must include a scheme")
|
|
661
|
+
}
|
|
662
|
+
if (scheme != "http" && scheme != "https") {
|
|
663
|
+
SniConnectLogger.warn(
|
|
664
|
+
SniConnectLogger.event(
|
|
665
|
+
"proxy_preflight",
|
|
666
|
+
"platform" to "android",
|
|
667
|
+
"scheme" to scheme,
|
|
668
|
+
"host" to "unknown",
|
|
669
|
+
"result" to "invalid_url",
|
|
670
|
+
"source" to "validation",
|
|
671
|
+
"proxyTypeCount" to 0,
|
|
672
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
673
|
+
),
|
|
674
|
+
)
|
|
675
|
+
throw IllegalArgumentException("Only http and https URLs are supported")
|
|
676
|
+
}
|
|
677
|
+
val host = uri.host
|
|
678
|
+
if (host.isNullOrBlank()) {
|
|
679
|
+
SniConnectLogger.warn(
|
|
680
|
+
SniConnectLogger.event(
|
|
681
|
+
"proxy_preflight",
|
|
682
|
+
"platform" to "android",
|
|
683
|
+
"scheme" to scheme,
|
|
684
|
+
"host" to "unknown",
|
|
685
|
+
"result" to "invalid_url",
|
|
686
|
+
"source" to "validation",
|
|
687
|
+
"proxyTypeCount" to 0,
|
|
688
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
689
|
+
),
|
|
690
|
+
)
|
|
691
|
+
throw IllegalArgumentException("URL must include a host")
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
val selector = ProxySelector.getDefault()
|
|
695
|
+
val selectorProxies = selector?.select(uri).orEmpty()
|
|
696
|
+
val selectorHasProxy = selectorProxies.any { proxy ->
|
|
697
|
+
proxy != Proxy.NO_PROXY && proxy.type() != Proxy.Type.DIRECT
|
|
698
|
+
}
|
|
699
|
+
if (selectorHasProxy) {
|
|
700
|
+
SniConnectLogger.info(
|
|
701
|
+
SniConnectLogger.event(
|
|
702
|
+
"proxy_preflight",
|
|
703
|
+
"platform" to "android",
|
|
704
|
+
"scheme" to scheme,
|
|
705
|
+
"host" to host,
|
|
706
|
+
"result" to true,
|
|
707
|
+
"source" to "ProxySelector",
|
|
708
|
+
"proxyTypeCount" to selectorProxies.map { proxy -> proxy.type().name }.toSet().size,
|
|
709
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
710
|
+
),
|
|
711
|
+
)
|
|
712
|
+
return true
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
val connectivityManager = reactApplicationContext.getSystemService(Context.CONNECTIVITY_SERVICE)
|
|
716
|
+
as? ConnectivityManager
|
|
717
|
+
?: run {
|
|
718
|
+
SniConnectLogger.info(
|
|
719
|
+
SniConnectLogger.event(
|
|
720
|
+
"proxy_preflight",
|
|
721
|
+
"platform" to "android",
|
|
722
|
+
"scheme" to scheme,
|
|
723
|
+
"host" to host,
|
|
724
|
+
"result" to false,
|
|
725
|
+
"source" to "none",
|
|
726
|
+
"proxyTypeCount" to selectorProxies.size,
|
|
727
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
728
|
+
),
|
|
729
|
+
)
|
|
730
|
+
return false
|
|
731
|
+
}
|
|
732
|
+
val activeNetworkProxy = connectivityManager.activeNetwork
|
|
733
|
+
?.let { network -> connectivityManager.getLinkProperties(network)?.httpProxy }
|
|
734
|
+
if (activeNetworkProxy?.host?.isNotBlank() == true && activeNetworkProxy.port > 0) {
|
|
735
|
+
SniConnectLogger.info(
|
|
736
|
+
SniConnectLogger.event(
|
|
737
|
+
"proxy_preflight",
|
|
738
|
+
"platform" to "android",
|
|
739
|
+
"scheme" to scheme,
|
|
740
|
+
"host" to host,
|
|
741
|
+
"result" to true,
|
|
742
|
+
"source" to "LinkProperties",
|
|
743
|
+
"proxyTypeCount" to selectorProxies.size,
|
|
744
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
745
|
+
),
|
|
746
|
+
)
|
|
747
|
+
return true
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
val defaultProxy = connectivityManager.defaultProxy
|
|
751
|
+
if (defaultProxy?.host?.isNotBlank() == true && defaultProxy.port > 0) {
|
|
752
|
+
SniConnectLogger.info(
|
|
753
|
+
SniConnectLogger.event(
|
|
754
|
+
"proxy_preflight",
|
|
755
|
+
"platform" to "android",
|
|
756
|
+
"scheme" to scheme,
|
|
757
|
+
"host" to host,
|
|
758
|
+
"result" to true,
|
|
759
|
+
"source" to "defaultProxy",
|
|
760
|
+
"proxyTypeCount" to selectorProxies.size,
|
|
761
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
762
|
+
),
|
|
763
|
+
)
|
|
764
|
+
return true
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
SniConnectLogger.info(
|
|
768
|
+
SniConnectLogger.event(
|
|
769
|
+
"proxy_preflight",
|
|
770
|
+
"platform" to "android",
|
|
771
|
+
"scheme" to scheme,
|
|
772
|
+
"host" to host,
|
|
773
|
+
"result" to false,
|
|
774
|
+
"source" to "none",
|
|
775
|
+
"proxyTypeCount" to selectorProxies.size,
|
|
776
|
+
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
777
|
+
),
|
|
778
|
+
)
|
|
779
|
+
return false
|
|
780
|
+
}
|
|
341
781
|
}
|