@onekeyfe/react-native-sni-connect 3.0.81-alpha.7 → 3.0.81

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 CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  cancelRequest,
39
39
  cancelAllRequests,
40
40
  clearDNSCache,
41
+ getDebugSnapshot,
41
42
  isProxyActiveForUrl,
42
43
  } from '@onekeyfe/react-native-sni-connect';
43
44
 
@@ -66,6 +67,12 @@ await cancelAllRequests();
66
67
 
67
68
  // Drop pinned-IP connections / cached clients
68
69
  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
+ });
69
76
  ```
70
77
 
71
78
  `multiValueHeaders` preserves repeated response headers when the native transport
@@ -13,9 +13,11 @@ import okhttp3.Call
13
13
  import okhttp3.Callback
14
14
  import okhttp3.ConnectionPool
15
15
  import okhttp3.Dispatcher
16
+ import okhttp3.Dns
16
17
  import okhttp3.Headers
17
18
  import okhttp3.MediaType.Companion.toMediaTypeOrNull
18
19
  import okhttp3.OkHttpClient
20
+ import okhttp3.Protocol
19
21
  import okhttp3.Request
20
22
  import okhttp3.RequestBody.Companion.toRequestBody
21
23
  import okhttp3.Response
@@ -23,6 +25,7 @@ import okhttp3.ResponseBody
23
25
  import okio.Buffer
24
26
  import java.io.IOException
25
27
  import java.io.InterruptedIOException
28
+ import java.net.InetAddress
26
29
  import java.net.Proxy
27
30
  import java.net.ProxySelector
28
31
  import java.net.URI
@@ -34,6 +37,7 @@ import java.util.Locale
34
37
  import java.util.concurrent.ConcurrentHashMap
35
38
  import java.util.concurrent.TimeUnit
36
39
  import java.util.concurrent.atomic.AtomicBoolean
40
+ import javax.net.ssl.HttpsURLConnection
37
41
  import javax.net.ssl.SSLException
38
42
  import javax.net.ssl.SSLPeerUnverifiedException
39
43
 
@@ -57,6 +61,15 @@ internal fun classifySniFailureCode(error: Throwable): String {
57
61
  return "SNI_REQUEST_FAILED"
58
62
  }
59
63
 
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
+
60
73
  private fun hasCause(error: Throwable, type: Class<out Throwable>): Boolean {
61
74
  var current: Throwable? = error
62
75
  while (current != null) {
@@ -77,12 +90,12 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
77
90
  // from JS-controlled host/IP pairs (e.g. speed-testing many endpoints).
78
91
  private const val MAX_CLIENTS = 32
79
92
 
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.
93
+ // Native resources are process-shared across the main and background RN runtimes.
82
94
  private val sharedDispatcher = Dispatcher().apply {
83
- maxRequests = 64
84
- maxRequestsPerHost = 64
95
+ maxRequests = SniConnectValidation.MAX_ACTIVE_REQUESTS
96
+ maxRequestsPerHost = SniConnectValidation.MAX_ACTIVE_REQUESTS
85
97
  }
98
+ private val sharedAdmission = SniConnectRequestAdmission()
86
99
  private val sharedConnectionPool = ConnectionPool()
87
100
  }
88
101
 
@@ -120,10 +133,33 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
120
133
  }
121
134
  }
122
135
 
123
- private val activeCalls = ConcurrentHashMap<String, Call>()
124
- private val allActiveCalls = Collections.newSetFromMap(ConcurrentHashMap<Call, Boolean>())
136
+ private class ManagedRequest(
137
+ val call: Call,
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
+ )
125
162
  private val activeCallsLock = Any()
126
- private val requestLimiter = SniConnectRequestLimiter()
127
163
 
128
164
  override fun getName(): String = NAME
129
165
 
@@ -147,11 +183,11 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
147
183
 
148
184
  @ReactMethod
149
185
  override fun cancelRequest(requestId: String, promise: Promise) {
150
- val call = synchronized(activeCallsLock) {
186
+ val request = synchronized(activeCallsLock) {
151
187
  activeCalls.remove(requestId)
152
188
  }
153
- if (call != null) {
154
- call.cancel()
189
+ if (request != null) {
190
+ request.cancel()
155
191
  SniConnectLogger.info(
156
192
  SniConnectLogger.event(
157
193
  "sni_cancel",
@@ -174,17 +210,17 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
174
210
 
175
211
  @ReactMethod
176
212
  override fun cancelAllRequests(promise: Promise) {
177
- val calls = synchronized(activeCallsLock) {
213
+ val requests = synchronized(activeCallsLock) {
178
214
  val snapshot = allActiveCalls.toList()
179
215
  activeCalls.clear()
180
216
  allActiveCalls.clear()
181
217
  snapshot
182
218
  }
183
- calls.forEach { call -> call.cancel() }
219
+ requests.forEach { request -> request.cancel() }
184
220
  SniConnectLogger.info(
185
221
  SniConnectLogger.event(
186
222
  "sni_cancel_all",
187
- "cancelledCount" to calls.size,
223
+ "cancelledCount" to requests.size,
188
224
  "success" to true,
189
225
  ),
190
226
  )
@@ -208,6 +244,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
208
244
  promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
209
245
  }
210
246
 
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
+
211
270
  @ReactMethod
212
271
  override fun isProxyActiveForUrl(url: String, promise: Promise) {
213
272
  try {
@@ -219,19 +278,14 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
219
278
 
220
279
  private fun performRequest(config: RequestConfig, promise: Promise) {
221
280
  val startedAtMs = android.os.SystemClock.elapsedRealtime()
222
- var requestSlot: SniConnectRequestLimiter.Token? = null
223
- var registeredCall: Call? = null
281
+ val startedAtNanos = System.nanoTime()
282
+ var registeredRequest: ManagedRequest? = null
224
283
  try {
225
- requestSlot = requestLimiter.acquire(config.hostname, config.ip)
226
284
  val client = getOrCreateClient(config)
227
285
  val request = buildRequest(config)
228
286
  val call = client.newCall(request)
229
-
230
- // Apply per-request timeout
231
- call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
232
-
233
- registerCall(config.requestId, call)
234
- registeredCall = call
287
+ val settled = AtomicBoolean(false)
288
+ lateinit var managedRequest: ManagedRequest
235
289
 
236
290
  SniConnectLogger.info(
237
291
  SniConnectLogger.event(
@@ -247,16 +301,13 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
247
301
  ),
248
302
  )
249
303
 
250
- // Guard against double-settling the promise (RN hard-crashes otherwise).
251
- val settled = AtomicBoolean(false)
252
-
253
- call.enqueue(object : Callback {
304
+ val callback = object : Callback {
254
305
  override fun onFailure(call: Call, e: IOException) {
255
- unregisterCall(config.requestId, call)
256
- requestSlot?.release()
306
+ unregisterRequest(config.requestId, managedRequest)
307
+ managedRequest.release()
257
308
  if (!settled.compareAndSet(false, true)) return
258
309
 
259
- if (call.isCanceled()) {
310
+ if (managedRequest.wasExplicitlyCancelled()) {
260
311
  promise.reject("SNI_CANCELLED", "Request cancelled", null)
261
312
  } else {
262
313
  val code = classifySniFailureCode(e)
@@ -310,17 +361,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
310
361
  SniConnectLogger.info(resultLog)
311
362
  }
312
363
  if (settled.compareAndSet(false, true)) {
313
- promise.resolve(result)
364
+ if (managedRequest.wasExplicitlyCancelled()) {
365
+ promise.reject("SNI_CANCELLED", "Request cancelled", null)
366
+ } else {
367
+ promise.resolve(result)
368
+ }
314
369
  }
315
370
  }
316
371
  }
317
372
  } catch (error: Exception) {
318
373
  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
+ }
319
382
  SniConnectLogger.error(
320
383
  SniConnectLogger.event(
321
384
  "sni_request_result",
322
385
  "result" to "error",
323
- "code" to "SNI_RESPONSE_FAILED",
386
+ "code" to code,
324
387
  "nativeErrorClass" to error.javaClass.simpleName,
325
388
  "requestIdHash" to SniConnectLogger.shortHash(config.requestId),
326
389
  "hostname" to config.hostname.lowercase(Locale.US),
@@ -331,22 +394,57 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
331
394
  "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
332
395
  ),
333
396
  )
334
- promise.reject("SNI_RESPONSE_FAILED", error.message, error)
397
+ promise.reject(code, error.message, error)
335
398
  } finally {
336
- unregisterCall(config.requestId, call)
337
- requestSlot?.release()
399
+ unregisterRequest(config.requestId, managedRequest)
400
+ managedRequest.release()
338
401
  }
339
402
  }
340
- })
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()
341
439
  } catch (error: Exception) {
342
- registeredCall?.let { call ->
343
- unregisterCall(config.requestId, call)
440
+ registeredRequest?.let { request ->
441
+ unregisterRequest(config.requestId, request)
442
+ request.release()
344
443
  }
345
- requestSlot?.release()
346
- val code = if (error is SniConnectValidation.ValidationException) {
347
- "SNI_RESOURCE_LIMIT"
348
- } else {
349
- "SNI_REQUEST_FAILED"
444
+ val code = when {
445
+ error is SniConnectValidation.ValidationException -> "SNI_RESOURCE_LIMIT"
446
+ hasCause(error, InterruptedIOException::class.java) -> "SNI_REQUEST_TIMEOUT"
447
+ else -> "SNI_REQUEST_FAILED"
350
448
  }
351
449
  SniConnectLogger.error(
352
450
  SniConnectLogger.event(
@@ -363,18 +461,21 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
363
461
  "elapsedMs" to SniConnectLogger.elapsedMs(startedAtMs),
364
462
  ),
365
463
  )
366
- promise.reject(code, error.message, error)
464
+ val shouldReject = registeredRequest?.settled?.compareAndSet(false, true) ?: true
465
+ if (shouldReject) {
466
+ promise.reject(code, error.message, error)
467
+ }
367
468
  }
368
469
  }
369
470
 
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)
471
+ private fun registerRequest(requestId: String?, request: ManagedRequest) {
472
+ val previousRequest = synchronized(activeCallsLock) {
473
+ val previous = requestId?.let { activeCalls.put(it, request) }
474
+ allActiveCalls.add(request)
374
475
  previous
375
476
  }
376
- if (previousCall != null && previousCall != call) {
377
- previousCall.cancel()
477
+ if (previousRequest != null && previousRequest != request) {
478
+ previousRequest.cancel()
378
479
  SniConnectLogger.warn(
379
480
  SniConnectLogger.event(
380
481
  "sni_duplicate_request_id",
@@ -385,28 +486,52 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
385
486
  }
386
487
  }
387
488
 
388
- private fun unregisterCall(requestId: String?, call: Call) {
489
+ private fun unregisterRequest(requestId: String?, request: ManagedRequest) {
389
490
  synchronized(activeCallsLock) {
390
491
  if (requestId != null) {
391
- activeCalls.remove(requestId, call)
492
+ activeCalls.remove(requestId, request)
392
493
  }
393
- allActiveCalls.remove(call)
494
+ allActiveCalls.remove(request)
394
495
  }
395
496
  }
396
497
 
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
+
397
507
  private fun getOrCreateClient(config: RequestConfig): OkHttpClient {
398
508
  val normalizedHost = config.hostname.lowercase(Locale.US)
399
- val key = ClientKey(normalizedHost, config.ip)
509
+ val key = ClientKey(
510
+ normalizedHost,
511
+ SniConnectValidation.canonicalizePublicIp(config.ip),
512
+ )
400
513
 
401
514
  synchronized(clientCache) {
402
515
  clientCache[key]?.let { return it }
403
516
 
404
- val client = SniPinnedTransport.createClient(
405
- ip = config.ip,
406
- hostname = config.hostname,
407
- dispatcher = sharedDispatcher,
408
- connectionPool = sharedConnectionPool,
409
- )
517
+ val client = OkHttpClient.Builder()
518
+ .dispatcher(sharedDispatcher)
519
+ .connectionPool(sharedConnectionPool)
520
+ .proxy(Proxy.NO_PROXY)
521
+ .protocols(listOf(Protocol.HTTP_1_1))
522
+ .connectTimeout(0, TimeUnit.MILLISECONDS)
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()
410
535
 
411
536
  clientCache[key] = client
412
537
  SniConnectLogger.info(
@@ -426,6 +551,29 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
426
551
  }
427
552
  }
428
553
 
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
+
429
577
  /**
430
578
  * Build the request. Always `https://<hostname><path>` on the implicit port 443 —
431
579
  * `path` has been validated as relative, so scheme/host/port cannot be overridden.
@@ -523,6 +671,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
523
671
  }
524
672
  }
525
673
 
674
+ private fun List<String>.toWritableArray() = Arguments.createArray().apply {
675
+ forEach { value -> pushString(value) }
676
+ }
677
+
526
678
  private fun ReadableMap.toRequestConfig(): RequestConfig {
527
679
  val rawHeadersMap = if (hasKey("headers") && !isNull("headers")) {
528
680
  getMap("headers")?.toHashMap()
@@ -540,7 +692,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
540
692
 
541
693
  val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
542
694
 
543
- val ip = getString("ip") ?: throw IllegalArgumentException("ip is required")
695
+ val rawIp = getString("ip") ?: throw IllegalArgumentException("ip is required")
544
696
  val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
545
697
  val method = getString("method") ?: "GET"
546
698
  val path = getString("path") ?: "/"
@@ -548,7 +700,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
548
700
 
549
701
  // Validate every caller-controlled field at the boundary.
550
702
  SniConnectValidation.validateRequestId(requestId)
551
- SniConnectValidation.validatePublicIp(ip)
703
+ val ip = SniConnectValidation.canonicalizePublicIp(rawIp)
552
704
  SniConnectValidation.validateHostname(hostname)
553
705
  val headersMap = SniConnectValidation.normalizeHeaders(rawHeadersMap)
554
706
  val normalizedMethod = SniConnectValidation.normalizeMethod(method)