@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 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
- // Native resources are process-shared across the main and background RN runtimes.
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 = SniConnectValidation.MAX_ACTIVE_REQUESTS
96
- maxRequestsPerHost = SniConnectValidation.MAX_ACTIVE_REQUESTS
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 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
- )
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 request = synchronized(activeCallsLock) {
150
+ val call = synchronized(activeCallsLock) {
187
151
  activeCalls.remove(requestId)
188
152
  }
189
- if (request != null) {
190
- request.cancel()
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 requests = synchronized(activeCallsLock) {
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
- requests.forEach { request -> request.cancel() }
183
+ calls.forEach { call -> call.cancel() }
220
184
  SniConnectLogger.info(
221
185
  SniConnectLogger.event(
222
186
  "sni_cancel_all",
223
- "cancelledCount" to requests.size,
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
- val startedAtNanos = System.nanoTime()
282
- var registeredRequest: ManagedRequest? = null
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
- val settled = AtomicBoolean(false)
288
- lateinit var managedRequest: ManagedRequest
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
- val callback = object : Callback {
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
- unregisterRequest(config.requestId, managedRequest)
307
- managedRequest.release()
255
+ unregisterCall(config.requestId, call)
256
+ requestSlot?.release()
308
257
  if (!settled.compareAndSet(false, true)) return
309
258
 
310
- if (managedRequest.wasExplicitlyCancelled()) {
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
- if (managedRequest.wasExplicitlyCancelled()) {
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 code,
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(code, error.message, error)
334
+ promise.reject("SNI_RESPONSE_FAILED", error.message, error)
398
335
  } finally {
399
- unregisterRequest(config.requestId, managedRequest)
400
- managedRequest.release()
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
- registeredRequest?.let { request ->
441
- unregisterRequest(config.requestId, request)
442
- request.release()
342
+ registeredCall?.let { call ->
343
+ unregisterCall(config.requestId, call)
443
344
  }
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"
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
- val shouldReject = registeredRequest?.settled?.compareAndSet(false, true) ?: true
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 registerRequest(requestId: String?, request: ManagedRequest) {
472
- val previousRequest = synchronized(activeCallsLock) {
473
- val previous = requestId?.let { activeCalls.put(it, request) }
474
- allActiveCalls.add(request)
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 (previousRequest != null && previousRequest != request) {
478
- previousRequest.cancel()
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 unregisterRequest(requestId: String?, request: ManagedRequest) {
388
+ private fun unregisterCall(requestId: String?, call: Call) {
490
389
  synchronized(activeCallsLock) {
491
390
  if (requestId != null) {
492
- activeCalls.remove(requestId, request)
391
+ activeCalls.remove(requestId, call)
493
392
  }
494
- allActiveCalls.remove(request)
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 = 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()
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 rawIp = getString("ip") ?: throw IllegalArgumentException("ip is required")
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
- val ip = SniConnectValidation.canonicalizePublicIp(rawIp)
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
+ }