@onekeyfe/react-native-sni-connect 3.0.81 → 3.0.85
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 +0 -7
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +61 -213
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +76 -6
- package/android/src/main/java/com/sniconnect/SniPinnedTransport.kt +58 -0
- package/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +26 -26
- package/ios/SniConnect.mm +0 -19
- package/ios/SniConnect.swift +0 -29
- package/ios/SniConnectClient.swift +86 -75
- package/ios/SniConnectCore.swift +4 -0
- package/ios/SniConnectPinnedTransport.swift +92 -0
- package/ios/SniConnectValidation.swift +41 -214
- package/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +17 -206
- package/lib/module/index.js +0 -3
- package/lib/typescript/src/NativeSniConnect.d.ts +0 -13
- package/lib/typescript/src/index.d.ts +2 -3
- package/package.json +1 -1
- package/src/NativeSniConnect.ts +0 -17
- package/src/index.tsx +0 -10
- package/android/src/main/java/com/sniconnect/SniConnectRequestAdmission.kt +0 -276
- package/android/src/test/java/com/sniconnect/SniConnectRequestAdmissionTest.kt +0 -299
package/README.md
CHANGED
|
@@ -38,7 +38,6 @@ import {
|
|
|
38
38
|
cancelRequest,
|
|
39
39
|
cancelAllRequests,
|
|
40
40
|
clearDNSCache,
|
|
41
|
-
getDebugSnapshot,
|
|
42
41
|
isProxyActiveForUrl,
|
|
43
42
|
} from '@onekeyfe/react-native-sni-connect';
|
|
44
43
|
|
|
@@ -67,12 +66,6 @@ await cancelAllRequests();
|
|
|
67
66
|
|
|
68
67
|
// Drop pinned-IP connections / cached clients
|
|
69
68
|
await clearDNSCache();
|
|
70
|
-
|
|
71
|
-
// Diagnostics for one validated hostname/IP pair
|
|
72
|
-
const snapshot = await getDebugSnapshot({
|
|
73
|
-
hostname: 'example.com',
|
|
74
|
-
ip: '93.184.216.34',
|
|
75
|
-
});
|
|
76
69
|
```
|
|
77
70
|
|
|
78
71
|
`multiValueHeaders` preserves repeated response headers when the native transport
|
|
@@ -13,11 +13,9 @@ import okhttp3.Call
|
|
|
13
13
|
import okhttp3.Callback
|
|
14
14
|
import okhttp3.ConnectionPool
|
|
15
15
|
import okhttp3.Dispatcher
|
|
16
|
-
import okhttp3.Dns
|
|
17
16
|
import okhttp3.Headers
|
|
18
17
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
19
18
|
import okhttp3.OkHttpClient
|
|
20
|
-
import okhttp3.Protocol
|
|
21
19
|
import okhttp3.Request
|
|
22
20
|
import okhttp3.RequestBody.Companion.toRequestBody
|
|
23
21
|
import okhttp3.Response
|
|
@@ -25,7 +23,6 @@ import okhttp3.ResponseBody
|
|
|
25
23
|
import okio.Buffer
|
|
26
24
|
import java.io.IOException
|
|
27
25
|
import java.io.InterruptedIOException
|
|
28
|
-
import java.net.InetAddress
|
|
29
26
|
import java.net.Proxy
|
|
30
27
|
import java.net.ProxySelector
|
|
31
28
|
import java.net.URI
|
|
@@ -37,7 +34,6 @@ import java.util.Locale
|
|
|
37
34
|
import java.util.concurrent.ConcurrentHashMap
|
|
38
35
|
import java.util.concurrent.TimeUnit
|
|
39
36
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
40
|
-
import javax.net.ssl.HttpsURLConnection
|
|
41
37
|
import javax.net.ssl.SSLException
|
|
42
38
|
import javax.net.ssl.SSLPeerUnverifiedException
|
|
43
39
|
|
|
@@ -61,15 +57,6 @@ internal fun classifySniFailureCode(error: Throwable): String {
|
|
|
61
57
|
return "SNI_REQUEST_FAILED"
|
|
62
58
|
}
|
|
63
59
|
|
|
64
|
-
internal fun classifySniResponseFailureCode(
|
|
65
|
-
error: Throwable,
|
|
66
|
-
explicitlyCancelled: Boolean,
|
|
67
|
-
): String = when {
|
|
68
|
-
explicitlyCancelled -> "SNI_CANCELLED"
|
|
69
|
-
hasCause(error, InterruptedIOException::class.java) -> "SNI_REQUEST_TIMEOUT"
|
|
70
|
-
else -> "SNI_RESPONSE_FAILED"
|
|
71
|
-
}
|
|
72
|
-
|
|
73
60
|
private fun hasCause(error: Throwable, type: Class<out Throwable>): Boolean {
|
|
74
61
|
var current: Throwable? = error
|
|
75
62
|
while (current != null) {
|
|
@@ -90,12 +77,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
90
77
|
// from JS-controlled host/IP pairs (e.g. speed-testing many endpoints).
|
|
91
78
|
private const val MAX_CLIENTS = 32
|
|
92
79
|
|
|
93
|
-
//
|
|
80
|
+
// A single dispatcher + connection pool shared across all cached clients so we
|
|
81
|
+
// don't spawn a thread pool / connection pool per (hostname, ip) pair.
|
|
94
82
|
private val sharedDispatcher = Dispatcher().apply {
|
|
95
|
-
maxRequests =
|
|
96
|
-
maxRequestsPerHost =
|
|
83
|
+
maxRequests = 64
|
|
84
|
+
maxRequestsPerHost = 64
|
|
97
85
|
}
|
|
98
|
-
private val sharedAdmission = SniConnectRequestAdmission()
|
|
99
86
|
private val sharedConnectionPool = ConnectionPool()
|
|
100
87
|
}
|
|
101
88
|
|
|
@@ -133,33 +120,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
133
120
|
}
|
|
134
121
|
}
|
|
135
122
|
|
|
136
|
-
private
|
|
137
|
-
|
|
138
|
-
val settled: AtomicBoolean,
|
|
139
|
-
) {
|
|
140
|
-
lateinit var admissionTicket: SniConnectRequestAdmission.Ticket
|
|
141
|
-
private val explicitlyCancelled = AtomicBoolean(false)
|
|
142
|
-
|
|
143
|
-
fun cancel() {
|
|
144
|
-
explicitlyCancelled.set(true)
|
|
145
|
-
if (!admissionTicket.cancelPending()) {
|
|
146
|
-
call.cancel()
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
fun release() {
|
|
151
|
-
admissionTicket.release()
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
fun wasExplicitlyCancelled(): Boolean = explicitlyCancelled.get()
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Cancellation ownership is per module, so cancelAllRequests only affects its RN runtime.
|
|
158
|
-
private val activeCalls = ConcurrentHashMap<String, ManagedRequest>()
|
|
159
|
-
private val allActiveCalls = Collections.newSetFromMap(
|
|
160
|
-
ConcurrentHashMap<ManagedRequest, Boolean>(),
|
|
161
|
-
)
|
|
123
|
+
private val activeCalls = ConcurrentHashMap<String, Call>()
|
|
124
|
+
private val allActiveCalls = Collections.newSetFromMap(ConcurrentHashMap<Call, Boolean>())
|
|
162
125
|
private val activeCallsLock = Any()
|
|
126
|
+
private val requestLimiter = SniConnectRequestLimiter()
|
|
163
127
|
|
|
164
128
|
override fun getName(): String = NAME
|
|
165
129
|
|
|
@@ -183,11 +147,11 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
183
147
|
|
|
184
148
|
@ReactMethod
|
|
185
149
|
override fun cancelRequest(requestId: String, promise: Promise) {
|
|
186
|
-
val
|
|
150
|
+
val call = synchronized(activeCallsLock) {
|
|
187
151
|
activeCalls.remove(requestId)
|
|
188
152
|
}
|
|
189
|
-
if (
|
|
190
|
-
|
|
153
|
+
if (call != null) {
|
|
154
|
+
call.cancel()
|
|
191
155
|
SniConnectLogger.info(
|
|
192
156
|
SniConnectLogger.event(
|
|
193
157
|
"sni_cancel",
|
|
@@ -210,17 +174,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
210
174
|
|
|
211
175
|
@ReactMethod
|
|
212
176
|
override fun cancelAllRequests(promise: Promise) {
|
|
213
|
-
val
|
|
177
|
+
val calls = synchronized(activeCallsLock) {
|
|
214
178
|
val snapshot = allActiveCalls.toList()
|
|
215
179
|
activeCalls.clear()
|
|
216
180
|
allActiveCalls.clear()
|
|
217
181
|
snapshot
|
|
218
182
|
}
|
|
219
|
-
|
|
183
|
+
calls.forEach { call -> call.cancel() }
|
|
220
184
|
SniConnectLogger.info(
|
|
221
185
|
SniConnectLogger.event(
|
|
222
186
|
"sni_cancel_all",
|
|
223
|
-
"cancelledCount" to
|
|
187
|
+
"cancelledCount" to calls.size,
|
|
224
188
|
"success" to true,
|
|
225
189
|
),
|
|
226
190
|
)
|
|
@@ -244,29 +208,6 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
244
208
|
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
245
209
|
}
|
|
246
210
|
|
|
247
|
-
@ReactMethod
|
|
248
|
-
override fun getDebugSnapshot(target: ReadableMap, promise: Promise) {
|
|
249
|
-
try {
|
|
250
|
-
val ip = target.getString("ip") ?: throw IllegalArgumentException("ip is required")
|
|
251
|
-
val hostname = target.getString("hostname")
|
|
252
|
-
?: throw IllegalArgumentException("hostname is required")
|
|
253
|
-
val canonicalIp = SniConnectValidation.canonicalizePublicIp(ip)
|
|
254
|
-
SniConnectValidation.validateHostname(hostname)
|
|
255
|
-
val snapshot = sharedAdmission.snapshot(hostname, canonicalIp)
|
|
256
|
-
|
|
257
|
-
promise.resolve(Arguments.createMap().apply {
|
|
258
|
-
putInt("activeRequests", snapshot.activeRequests)
|
|
259
|
-
putInt("activeRequestsForPair", snapshot.activeRequestsForPair)
|
|
260
|
-
putInt("pendingRequests", snapshot.pendingRequests)
|
|
261
|
-
putInt("pendingRequestsForPair", snapshot.pendingRequestsForPair)
|
|
262
|
-
putArray("activeRequestIdsForPair", snapshot.activeRequestIdsForPair.toWritableArray())
|
|
263
|
-
putArray("pendingRequestIdsForPair", snapshot.pendingRequestIdsForPair.toWritableArray())
|
|
264
|
-
})
|
|
265
|
-
} catch (error: Exception) {
|
|
266
|
-
promise.reject("SNI_INVALID_CONFIG", error.message, error)
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
|
|
270
211
|
@ReactMethod
|
|
271
212
|
override fun isProxyActiveForUrl(url: String, promise: Promise) {
|
|
272
213
|
try {
|
|
@@ -278,14 +219,19 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
278
219
|
|
|
279
220
|
private fun performRequest(config: RequestConfig, promise: Promise) {
|
|
280
221
|
val startedAtMs = android.os.SystemClock.elapsedRealtime()
|
|
281
|
-
|
|
282
|
-
var
|
|
222
|
+
var requestSlot: SniConnectRequestLimiter.Token? = null
|
|
223
|
+
var registeredCall: Call? = null
|
|
283
224
|
try {
|
|
225
|
+
requestSlot = requestLimiter.acquire(config.hostname, config.ip)
|
|
284
226
|
val client = getOrCreateClient(config)
|
|
285
227
|
val request = buildRequest(config)
|
|
286
228
|
val call = client.newCall(request)
|
|
287
|
-
|
|
288
|
-
|
|
229
|
+
|
|
230
|
+
// Apply per-request timeout
|
|
231
|
+
call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
|
|
232
|
+
|
|
233
|
+
registerCall(config.requestId, call)
|
|
234
|
+
registeredCall = call
|
|
289
235
|
|
|
290
236
|
SniConnectLogger.info(
|
|
291
237
|
SniConnectLogger.event(
|
|
@@ -301,13 +247,16 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
301
247
|
),
|
|
302
248
|
)
|
|
303
249
|
|
|
304
|
-
|
|
250
|
+
// Guard against double-settling the promise (RN hard-crashes otherwise).
|
|
251
|
+
val settled = AtomicBoolean(false)
|
|
252
|
+
|
|
253
|
+
call.enqueue(object : Callback {
|
|
305
254
|
override fun onFailure(call: Call, e: IOException) {
|
|
306
|
-
|
|
307
|
-
|
|
255
|
+
unregisterCall(config.requestId, call)
|
|
256
|
+
requestSlot?.release()
|
|
308
257
|
if (!settled.compareAndSet(false, true)) return
|
|
309
258
|
|
|
310
|
-
if (
|
|
259
|
+
if (call.isCanceled()) {
|
|
311
260
|
promise.reject("SNI_CANCELLED", "Request cancelled", null)
|
|
312
261
|
} else {
|
|
313
262
|
val code = classifySniFailureCode(e)
|
|
@@ -361,29 +310,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
361
310
|
SniConnectLogger.info(resultLog)
|
|
362
311
|
}
|
|
363
312
|
if (settled.compareAndSet(false, true)) {
|
|
364
|
-
|
|
365
|
-
promise.reject("SNI_CANCELLED", "Request cancelled", null)
|
|
366
|
-
} else {
|
|
367
|
-
promise.resolve(result)
|
|
368
|
-
}
|
|
313
|
+
promise.resolve(result)
|
|
369
314
|
}
|
|
370
315
|
}
|
|
371
316
|
}
|
|
372
317
|
} catch (error: Exception) {
|
|
373
318
|
if (!settled.compareAndSet(false, true)) return
|
|
374
|
-
val code = classifySniResponseFailureCode(
|
|
375
|
-
error,
|
|
376
|
-
managedRequest.wasExplicitlyCancelled(),
|
|
377
|
-
)
|
|
378
|
-
if (code == "SNI_CANCELLED") {
|
|
379
|
-
promise.reject(code, "Request cancelled", null)
|
|
380
|
-
return
|
|
381
|
-
}
|
|
382
319
|
SniConnectLogger.error(
|
|
383
320
|
SniConnectLogger.event(
|
|
384
321
|
"sni_request_result",
|
|
385
322
|
"result" to "error",
|
|
386
|
-
"code" to
|
|
323
|
+
"code" to "SNI_RESPONSE_FAILED",
|
|
387
324
|
"nativeErrorClass" to error.javaClass.simpleName,
|
|
388
325
|
"requestIdHash" to SniConnectLogger.shortHash(config.requestId),
|
|
389
326
|
"hostname" to config.hostname.lowercase(Locale.US),
|
|
@@ -394,57 +331,22 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
394
331
|
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
395
332
|
),
|
|
396
333
|
)
|
|
397
|
-
promise.reject(
|
|
334
|
+
promise.reject("SNI_RESPONSE_FAILED", error.message, error)
|
|
398
335
|
} finally {
|
|
399
|
-
|
|
400
|
-
|
|
336
|
+
unregisterCall(config.requestId, call)
|
|
337
|
+
requestSlot?.release()
|
|
401
338
|
}
|
|
402
339
|
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
val timeoutBeforeAdmission = remainingTimeoutMillis(
|
|
406
|
-
config.timeoutMillis,
|
|
407
|
-
startedAtNanos,
|
|
408
|
-
)
|
|
409
|
-
val admissionTicket = sharedAdmission.createTicket(
|
|
410
|
-
hostname = config.hostname,
|
|
411
|
-
ip = config.ip,
|
|
412
|
-
requestId = config.requestId,
|
|
413
|
-
timeoutMillis = timeoutBeforeAdmission,
|
|
414
|
-
onAdmitted = { remainingTimeoutMillis ->
|
|
415
|
-
try {
|
|
416
|
-
call.timeout().timeout(remainingTimeoutMillis, TimeUnit.MILLISECONDS)
|
|
417
|
-
call.enqueue(callback)
|
|
418
|
-
} catch (error: Exception) {
|
|
419
|
-
unregisterRequest(config.requestId, managedRequest)
|
|
420
|
-
managedRequest.release()
|
|
421
|
-
if (settled.compareAndSet(false, true)) {
|
|
422
|
-
promise.reject("SNI_REQUEST_FAILED", error.message, error)
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
},
|
|
426
|
-
onPendingFailure = { code, message ->
|
|
427
|
-
unregisterRequest(config.requestId, managedRequest)
|
|
428
|
-
if (settled.compareAndSet(false, true)) {
|
|
429
|
-
promise.reject(code, message, null)
|
|
430
|
-
}
|
|
431
|
-
},
|
|
432
|
-
)
|
|
433
|
-
managedRequest = ManagedRequest(call, settled).apply {
|
|
434
|
-
this.admissionTicket = admissionTicket
|
|
435
|
-
}
|
|
436
|
-
registerRequest(config.requestId, managedRequest)
|
|
437
|
-
registeredRequest = managedRequest
|
|
438
|
-
admissionTicket.submit()
|
|
340
|
+
})
|
|
439
341
|
} catch (error: Exception) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
request.release()
|
|
342
|
+
registeredCall?.let { call ->
|
|
343
|
+
unregisterCall(config.requestId, call)
|
|
443
344
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
345
|
+
requestSlot?.release()
|
|
346
|
+
val code = if (error is SniConnectValidation.ValidationException) {
|
|
347
|
+
"SNI_RESOURCE_LIMIT"
|
|
348
|
+
} else {
|
|
349
|
+
"SNI_REQUEST_FAILED"
|
|
448
350
|
}
|
|
449
351
|
SniConnectLogger.error(
|
|
450
352
|
SniConnectLogger.event(
|
|
@@ -461,21 +363,18 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
461
363
|
"elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
|
|
462
364
|
),
|
|
463
365
|
)
|
|
464
|
-
|
|
465
|
-
if (shouldReject) {
|
|
466
|
-
promise.reject(code, error.message, error)
|
|
467
|
-
}
|
|
366
|
+
promise.reject(code, error.message, error)
|
|
468
367
|
}
|
|
469
368
|
}
|
|
470
369
|
|
|
471
|
-
private fun
|
|
472
|
-
val
|
|
473
|
-
val previous = requestId?.let { activeCalls.put(it,
|
|
474
|
-
allActiveCalls.add(
|
|
370
|
+
private fun registerCall(requestId: String?, call: Call) {
|
|
371
|
+
val previousCall = synchronized(activeCallsLock) {
|
|
372
|
+
val previous = requestId?.let { activeCalls.put(it, call) }
|
|
373
|
+
allActiveCalls.add(call)
|
|
475
374
|
previous
|
|
476
375
|
}
|
|
477
|
-
if (
|
|
478
|
-
|
|
376
|
+
if (previousCall != null && previousCall != call) {
|
|
377
|
+
previousCall.cancel()
|
|
479
378
|
SniConnectLogger.warn(
|
|
480
379
|
SniConnectLogger.event(
|
|
481
380
|
"sni_duplicate_request_id",
|
|
@@ -486,52 +385,28 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
486
385
|
}
|
|
487
386
|
}
|
|
488
387
|
|
|
489
|
-
private fun
|
|
388
|
+
private fun unregisterCall(requestId: String?, call: Call) {
|
|
490
389
|
synchronized(activeCallsLock) {
|
|
491
390
|
if (requestId != null) {
|
|
492
|
-
activeCalls.remove(requestId,
|
|
391
|
+
activeCalls.remove(requestId, call)
|
|
493
392
|
}
|
|
494
|
-
allActiveCalls.remove(
|
|
393
|
+
allActiveCalls.remove(call)
|
|
495
394
|
}
|
|
496
395
|
}
|
|
497
396
|
|
|
498
|
-
private fun remainingTimeoutMillis(totalTimeoutMillis: Long, startedAtNanos: Long): Long {
|
|
499
|
-
val elapsedNanos = (System.nanoTime() - startedAtNanos).coerceAtLeast(0L)
|
|
500
|
-
val remainingNanos = TimeUnit.MILLISECONDS.toNanos(totalTimeoutMillis) - elapsedNanos
|
|
501
|
-
if (remainingNanos <= 0L) {
|
|
502
|
-
throw java.net.SocketTimeoutException("Request timed out before admission")
|
|
503
|
-
}
|
|
504
|
-
return ((remainingNanos + 999_999L) / 1_000_000L).coerceAtLeast(1L)
|
|
505
|
-
}
|
|
506
|
-
|
|
507
397
|
private fun getOrCreateClient(config: RequestConfig): OkHttpClient {
|
|
508
398
|
val normalizedHost = config.hostname.lowercase(Locale.US)
|
|
509
|
-
val key = ClientKey(
|
|
510
|
-
normalizedHost,
|
|
511
|
-
SniConnectValidation.canonicalizePublicIp(config.ip),
|
|
512
|
-
)
|
|
399
|
+
val key = ClientKey(normalizedHost, config.ip)
|
|
513
400
|
|
|
514
401
|
synchronized(clientCache) {
|
|
515
402
|
clientCache[key]?.let { return it }
|
|
516
403
|
|
|
517
|
-
val client =
|
|
518
|
-
.
|
|
519
|
-
.
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
524
|
-
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
|
525
|
-
.callTimeout(0, TimeUnit.MILLISECONDS)
|
|
526
|
-
.followRedirects(false)
|
|
527
|
-
.followSslRedirects(false)
|
|
528
|
-
// TLS is validated normally: cert chain via the default trust manager and
|
|
529
|
-
// hostname verification against the REAL hostname (not the pinned IP).
|
|
530
|
-
.hostnameVerifier { _, session ->
|
|
531
|
-
HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session)
|
|
532
|
-
}
|
|
533
|
-
.dns(createPinnedDns(key.ip, config.hostname))
|
|
534
|
-
.build()
|
|
404
|
+
val client = SniPinnedTransport.createClient(
|
|
405
|
+
ip = config.ip,
|
|
406
|
+
hostname = config.hostname,
|
|
407
|
+
dispatcher = sharedDispatcher,
|
|
408
|
+
connectionPool = sharedConnectionPool,
|
|
409
|
+
)
|
|
535
410
|
|
|
536
411
|
clientCache[key] = client
|
|
537
412
|
SniConnectLogger.info(
|
|
@@ -551,29 +426,6 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
551
426
|
}
|
|
552
427
|
}
|
|
553
428
|
|
|
554
|
-
private fun createPinnedDns(ip: String, hostname: String): Dns =
|
|
555
|
-
object : Dns {
|
|
556
|
-
private val expectedHost = hostname.lowercase(Locale.US)
|
|
557
|
-
// Resolve the literal IP once up front (validated; never triggers DNS).
|
|
558
|
-
private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip)
|
|
559
|
-
|
|
560
|
-
override fun lookup(requestedHost: String): List<InetAddress> {
|
|
561
|
-
return if (requestedHost.lowercase(Locale.US) == expectedHost) {
|
|
562
|
-
listOf(pinnedAddress)
|
|
563
|
-
} else {
|
|
564
|
-
SniConnectLogger.warn(
|
|
565
|
-
SniConnectLogger.event(
|
|
566
|
-
"sni_pinned_dns_unexpected_host",
|
|
567
|
-
"expectedHost" to expectedHost,
|
|
568
|
-
"requestedHostHash" to SniConnectLogger.shortHash(requestedHost.lowercase(Locale.US)),
|
|
569
|
-
"result" to "fail_closed",
|
|
570
|
-
),
|
|
571
|
-
)
|
|
572
|
-
throw UnknownHostException("Unexpected host for pinned SNI request: $requestedHost")
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
429
|
/**
|
|
578
430
|
* Build the request. Always `https://<hostname><path>` on the implicit port 443 —
|
|
579
431
|
* `path` has been validated as relative, so scheme/host/port cannot be overridden.
|
|
@@ -671,10 +523,6 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
671
523
|
}
|
|
672
524
|
}
|
|
673
525
|
|
|
674
|
-
private fun List<String>.toWritableArray() = Arguments.createArray().apply {
|
|
675
|
-
forEach { value -> pushString(value) }
|
|
676
|
-
}
|
|
677
|
-
|
|
678
526
|
private fun ReadableMap.toRequestConfig(): RequestConfig {
|
|
679
527
|
val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
|
|
680
528
|
getMap("headers")?.toHashMap()
|
|
@@ -692,7 +540,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
692
540
|
|
|
693
541
|
val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
|
|
694
542
|
|
|
695
|
-
val
|
|
543
|
+
val ip = getString("ip") ?: throw IllegalArgumentException("ip is required")
|
|
696
544
|
val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
|
|
697
545
|
val method = getString("method") ?: "GET"
|
|
698
546
|
val path = getString("path") ?: "/"
|
|
@@ -700,7 +548,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
700
548
|
|
|
701
549
|
// Validate every caller-controlled field at the boundary.
|
|
702
550
|
SniConnectValidation.validateRequestId(requestId)
|
|
703
|
-
|
|
551
|
+
SniConnectValidation.validatePublicIp(ip)
|
|
704
552
|
SniConnectValidation.validateHostname(hostname)
|
|
705
553
|
val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
|
|
706
554
|
val normalizedMethod = SniConnectValidation.normalizeMethod(method)
|
|
@@ -4,6 +4,7 @@ import java.net.Inet6Address
|
|
|
4
4
|
import java.net.InetAddress
|
|
5
5
|
import java.nio.charset.StandardCharsets
|
|
6
6
|
import java.util.Locale
|
|
7
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Boundary validation/normalization for SNI request inputs.
|
|
@@ -28,7 +29,6 @@ internal object SniConnectValidation {
|
|
|
28
29
|
const val MAX_TOTAL_HEADER_BYTES = 32 * 1024
|
|
29
30
|
const val MAX_ACTIVE_REQUESTS = 64
|
|
30
31
|
const val MAX_ACTIVE_REQUESTS_PER_PAIR = 16
|
|
31
|
-
const val MAX_PENDING_REQUESTS = 256
|
|
32
32
|
|
|
33
33
|
private val ALLOWED_METHODS =
|
|
34
34
|
setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS")
|
|
@@ -225,11 +225,6 @@ internal object SniConnectValidation {
|
|
|
225
225
|
throw ValidationException("Invalid IP: $ip")
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
-
fun canonicalizePublicIp(ip: String): String {
|
|
229
|
-
validatePublicIp(ip)
|
|
230
|
-
return literalToInetAddress(ip).hostAddress
|
|
231
|
-
}
|
|
232
|
-
|
|
233
228
|
private fun isForbiddenIpv4(o: List<Int>): Boolean {
|
|
234
229
|
val a = o[0]; val b = o[1]; val c = o[2]; val d = o[3]
|
|
235
230
|
return when {
|
|
@@ -318,3 +313,78 @@ internal object SniConnectValidation {
|
|
|
318
313
|
return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal
|
|
319
314
|
}
|
|
320
315
|
}
|
|
316
|
+
|
|
317
|
+
internal class SniConnectRequestLimiter(
|
|
318
|
+
private val maxActiveRequests: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS,
|
|
319
|
+
private val maxActiveRequestsPerPair: Int = SniConnectValidation.MAX_ACTIVE_REQUESTS_PER_PAIR,
|
|
320
|
+
) {
|
|
321
|
+
private val lock = Any()
|
|
322
|
+
private var activeRequests = 0
|
|
323
|
+
private val activeRequestsByPair = mutableMapOf<String, Int>()
|
|
324
|
+
|
|
325
|
+
fun acquire(hostname: String, ip: String): Token {
|
|
326
|
+
val key = pairKey(hostname, ip)
|
|
327
|
+
synchronized(lock) {
|
|
328
|
+
if (activeRequests >= maxActiveRequests) {
|
|
329
|
+
SniConnectLogger.warn(
|
|
330
|
+
SniConnectLogger.event(
|
|
331
|
+
"sni_resource_limit",
|
|
332
|
+
"activeCount" to activeRequests,
|
|
333
|
+
"pairCount" to (activeRequestsByPair[key] ?: 0),
|
|
334
|
+
"limit" to maxActiveRequests,
|
|
335
|
+
"reason" to "max_active_requests",
|
|
336
|
+
"hostname" to hostname.lowercase(Locale.US),
|
|
337
|
+
"ipHash" to SniConnectLogger.shortHash(ip),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
throw SniConnectValidation.ValidationException("Too many active SNI requests")
|
|
341
|
+
}
|
|
342
|
+
val pairCount = activeRequestsByPair[key] ?: 0
|
|
343
|
+
if (pairCount >= maxActiveRequestsPerPair) {
|
|
344
|
+
SniConnectLogger.warn(
|
|
345
|
+
SniConnectLogger.event(
|
|
346
|
+
"sni_resource_limit",
|
|
347
|
+
"activeCount" to activeRequests,
|
|
348
|
+
"pairCount" to pairCount,
|
|
349
|
+
"limit" to maxActiveRequestsPerPair,
|
|
350
|
+
"reason" to "max_active_requests_per_pair",
|
|
351
|
+
"hostname" to hostname.lowercase(Locale.US),
|
|
352
|
+
"ipHash" to SniConnectLogger.shortHash(ip),
|
|
353
|
+
),
|
|
354
|
+
)
|
|
355
|
+
throw SniConnectValidation.ValidationException("Too many active SNI requests for destination")
|
|
356
|
+
}
|
|
357
|
+
activeRequests += 1
|
|
358
|
+
activeRequestsByPair[key] = pairCount + 1
|
|
359
|
+
}
|
|
360
|
+
return Token(this, key)
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private fun release(key: String) {
|
|
364
|
+
synchronized(lock) {
|
|
365
|
+
activeRequests = (activeRequests - 1).coerceAtLeast(0)
|
|
366
|
+
val pairCount = activeRequestsByPair[key] ?: return
|
|
367
|
+
if (pairCount <= 1) {
|
|
368
|
+
activeRequestsByPair.remove(key)
|
|
369
|
+
} else {
|
|
370
|
+
activeRequestsByPair[key] = pairCount - 1
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
private fun pairKey(hostname: String, ip: String): String =
|
|
376
|
+
"${hostname.lowercase(Locale.US)}|$ip"
|
|
377
|
+
|
|
378
|
+
class Token internal constructor(
|
|
379
|
+
private val limiter: SniConnectRequestLimiter,
|
|
380
|
+
private val key: String,
|
|
381
|
+
) {
|
|
382
|
+
private val released = AtomicBoolean(false)
|
|
383
|
+
|
|
384
|
+
fun release() {
|
|
385
|
+
if (released.compareAndSet(false, true)) {
|
|
386
|
+
limiter.release(key)
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
package com.sniconnect
|
|
2
|
+
|
|
3
|
+
import java.net.InetAddress
|
|
4
|
+
import java.net.Proxy
|
|
5
|
+
import java.net.UnknownHostException
|
|
6
|
+
import java.util.Locale
|
|
7
|
+
import java.util.concurrent.TimeUnit
|
|
8
|
+
import okhttp3.ConnectionPool
|
|
9
|
+
import okhttp3.Dispatcher
|
|
10
|
+
import okhttp3.Dns
|
|
11
|
+
import okhttp3.OkHttpClient
|
|
12
|
+
import okhttp3.Protocol
|
|
13
|
+
import javax.net.ssl.HttpsURLConnection
|
|
14
|
+
|
|
15
|
+
object SniPinnedTransport {
|
|
16
|
+
@JvmStatic
|
|
17
|
+
fun createClient(
|
|
18
|
+
ip: String,
|
|
19
|
+
hostname: String,
|
|
20
|
+
dispatcher: Dispatcher = Dispatcher(),
|
|
21
|
+
connectionPool: ConnectionPool = ConnectionPool(),
|
|
22
|
+
): OkHttpClient {
|
|
23
|
+
SniConnectValidation.validatePublicIp(ip)
|
|
24
|
+
SniConnectValidation.validateHostname(hostname)
|
|
25
|
+
val normalizedHostname = hostname.lowercase(Locale.US)
|
|
26
|
+
return OkHttpClient.Builder()
|
|
27
|
+
.dispatcher(dispatcher)
|
|
28
|
+
.connectionPool(connectionPool)
|
|
29
|
+
.proxy(Proxy.NO_PROXY)
|
|
30
|
+
.protocols(listOf(Protocol.HTTP_1_1))
|
|
31
|
+
.connectTimeout(0, TimeUnit.MILLISECONDS)
|
|
32
|
+
.readTimeout(0, TimeUnit.MILLISECONDS)
|
|
33
|
+
.writeTimeout(0, TimeUnit.MILLISECONDS)
|
|
34
|
+
.callTimeout(0, TimeUnit.MILLISECONDS)
|
|
35
|
+
.followRedirects(false)
|
|
36
|
+
.followSslRedirects(false)
|
|
37
|
+
.hostnameVerifier { _, session ->
|
|
38
|
+
HttpsURLConnection.getDefaultHostnameVerifier().verify(normalizedHostname, session)
|
|
39
|
+
}
|
|
40
|
+
.dns(createPinnedDns(ip, normalizedHostname))
|
|
41
|
+
.build()
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
private fun createPinnedDns(ip: String, hostname: String): Dns =
|
|
45
|
+
object : Dns {
|
|
46
|
+
private val pinnedAddress: InetAddress =
|
|
47
|
+
SniConnectValidation.literalToInetAddress(ip)
|
|
48
|
+
|
|
49
|
+
override fun lookup(requestedHost: String): List<InetAddress> {
|
|
50
|
+
if (requestedHost.lowercase(Locale.US) == hostname) {
|
|
51
|
+
return listOf(pinnedAddress)
|
|
52
|
+
}
|
|
53
|
+
throw UnknownHostException(
|
|
54
|
+
"Unexpected host for pinned SNI request: $requestedHost"
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|