@onekeyfe/react-native-sni-connect 1.0.0 → 3.0.69
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 -19
- package/SniConnect.podspec +2 -2
- package/android/build.gradle +0 -21
- package/android/src/main/java/com/sniconnect/SniConnectLogger.kt +55 -0
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +116 -121
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +144 -0
- package/ios/SniConnect-Bridging-Header.h +0 -1
- package/ios/SniConnect.mm +19 -41
- package/ios/SniConnect.swift +32 -55
- package/ios/SniConnectClient.swift +83 -101
- package/ios/SniConnectLog.swift +29 -0
- package/ios/SniConnectValidation.swift +151 -0
- package/lib/module/index.js +0 -15
- package/lib/typescript/src/NativeSniConnect.d.ts +1 -14
- package/lib/typescript/src/index.d.ts +0 -6
- package/package.json +56 -66
- package/src/NativeSniConnect.ts +1 -10
- package/src/index.tsx +0 -20
- package/LICENSE +0 -20
- package/lib/module/@types/react-native-codegen.d.js.map +0 -1
- package/lib/module/NativeSniConnect.js.map +0 -1
- package/lib/module/index.js.map +0 -1
- package/lib/typescript/src/NativeSniConnect.d.ts.map +0 -1
- package/lib/typescript/src/index.d.ts.map +0 -1
package/README.md
CHANGED
|
@@ -1,37 +1,62 @@
|
|
|
1
|
-
# react-native-sni-connect
|
|
1
|
+
# @onekeyfe/react-native-sni-connect
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
OneKey SNI HTTP client for React Native. Performs HTTPS requests to a caller-supplied
|
|
4
|
+
IP address while preserving the original TLS SNI / `Host` of a hostname, so certificate
|
|
5
|
+
chain and hostname verification are still enforced against the real hostname (not the IP).
|
|
6
|
+
|
|
7
|
+
Backed by EMASCurl (libcurl) on iOS and OkHttp on Android.
|
|
4
8
|
|
|
5
9
|
## Installation
|
|
6
10
|
|
|
11
|
+
This package is published as part of the OneKey `app-modules` workspace:
|
|
7
12
|
|
|
8
13
|
```sh
|
|
9
|
-
|
|
14
|
+
yarn add @onekeyfe/react-native-sni-connect
|
|
10
15
|
```
|
|
11
16
|
|
|
17
|
+
iOS: run `pod install`. Android autolinks.
|
|
12
18
|
|
|
13
19
|
## Usage
|
|
14
20
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
request,
|
|
24
|
+
cancelRequest,
|
|
25
|
+
cancelAllRequests,
|
|
26
|
+
clearDNSCache,
|
|
27
|
+
} from '@onekeyfe/react-native-sni-connect';
|
|
28
|
+
|
|
29
|
+
const res = await request({
|
|
30
|
+
// requestId is optional; required only if you want to cancel the request.
|
|
31
|
+
requestId: 'health-check-1',
|
|
32
|
+
ip: '93.184.216.34', // must be a public IP literal (private/loopback/metadata are rejected)
|
|
33
|
+
hostname: 'example.com', // used for SNI, Host header and certificate validation
|
|
34
|
+
method: 'GET',
|
|
35
|
+
path: '/api/v1/ping', // relative path only — absolute URLs are rejected
|
|
36
|
+
headers: { 'Content-Type': 'application/json' },
|
|
37
|
+
timeout: 30_000,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
console.log(res.status, res.headers, res.data);
|
|
41
|
+
|
|
42
|
+
// Cancellation (requires requestId on the request)
|
|
43
|
+
await cancelRequest('health-check-1');
|
|
44
|
+
await cancelAllRequests();
|
|
45
|
+
|
|
46
|
+
// Drop pinned-IP connections / cached clients
|
|
47
|
+
await clearDNSCache();
|
|
22
48
|
```
|
|
23
49
|
|
|
50
|
+
### Security notes
|
|
24
51
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
-
|
|
28
|
-
-
|
|
29
|
-
|
|
52
|
+
- The request scheme is always `https` on port `443`; `path` cannot override scheme, host or port.
|
|
53
|
+
- `ip` must be an IPv4/IPv6 literal that routes to a public destination; loopback, private,
|
|
54
|
+
link-local (incl. cloud metadata), CGNAT, multicast and reserved ranges are rejected.
|
|
55
|
+
- Header names/values containing CR/LF/control characters are rejected; the `Host` header is
|
|
56
|
+
managed by the module.
|
|
57
|
+
- Native logs go through OneKey's native logger (with sensitive-data redaction); there is no
|
|
58
|
+
JS log event channel.
|
|
30
59
|
|
|
31
60
|
## License
|
|
32
61
|
|
|
33
62
|
MIT
|
|
34
|
-
|
|
35
|
-
---
|
|
36
|
-
|
|
37
|
-
Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob)
|
package/SniConnect.podspec
CHANGED
|
@@ -11,7 +11,7 @@ Pod::Spec.new do |s|
|
|
|
11
11
|
s.authors = package["author"]
|
|
12
12
|
|
|
13
13
|
s.platforms = { :ios => min_ios_version_supported }
|
|
14
|
-
s.source = { :git => "https://github.com/OneKeyHQ/react-native-sni-connect.git", :tag => "#{s.version}" }
|
|
14
|
+
s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git", :tag => "#{s.version}" }
|
|
15
15
|
|
|
16
16
|
s.static_framework = true
|
|
17
17
|
|
|
@@ -24,7 +24,7 @@ Pod::Spec.new do |s|
|
|
|
24
24
|
s.dependency 'React-jsi'
|
|
25
25
|
s.dependency 'React-callinvoker'
|
|
26
26
|
s.dependency 'React-Codegen'
|
|
27
|
-
s.dependency 'EMASCurl', '1.
|
|
27
|
+
s.dependency 'EMASCurl', '1.5.5'
|
|
28
28
|
|
|
29
29
|
s.pod_target_xcconfig = {
|
|
30
30
|
"HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\"",
|
package/android/build.gradle
CHANGED
|
@@ -34,27 +34,6 @@ android {
|
|
|
34
34
|
targetSdkVersion getExtOrIntegerDefault("targetSdkVersion")
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
packagingOptions {
|
|
38
|
-
excludes = [
|
|
39
|
-
"META-INF",
|
|
40
|
-
"META-INF/**",
|
|
41
|
-
"**/libc++_shared.so",
|
|
42
|
-
"**/libfbjni.so",
|
|
43
|
-
"**/libjsi.so",
|
|
44
|
-
"**/libfolly_json.so",
|
|
45
|
-
"**/libfolly_runtime.so",
|
|
46
|
-
"**/libglog.so",
|
|
47
|
-
"**/libhermes.so",
|
|
48
|
-
"**/libhermes-executor-debug.so",
|
|
49
|
-
"**/libhermes_executor.so",
|
|
50
|
-
"**/libreactnative.so",
|
|
51
|
-
"**/libreactnativejni.so",
|
|
52
|
-
"**/libturbomodulejsijni.so",
|
|
53
|
-
"**/libreact_nativemodule_core.so",
|
|
54
|
-
"**/libjscexecutor.so"
|
|
55
|
-
]
|
|
56
|
-
}
|
|
57
|
-
|
|
58
37
|
buildFeatures {
|
|
59
38
|
buildConfig true
|
|
60
39
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
package com.sniconnect
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Lightweight logging wrapper that dynamically dispatches to OneKeyLog.
|
|
5
|
+
* Uses reflection to avoid a hard dependency on the (nitro-based) native-logger
|
|
6
|
+
* module. Falls back to android.util.Log when OneKeyLog is not available.
|
|
7
|
+
*
|
|
8
|
+
* Mirrors iOS SniConnectLog and the existing BTLogger / SBLLogger.
|
|
9
|
+
*/
|
|
10
|
+
internal object SniConnectLogger {
|
|
11
|
+
private const val TAG = "SniConnect"
|
|
12
|
+
|
|
13
|
+
private val logClass: Class<*>? by lazy {
|
|
14
|
+
try {
|
|
15
|
+
Class.forName("com.margelo.nitro.nativelogger.OneKeyLog")
|
|
16
|
+
} catch (_: ClassNotFoundException) {
|
|
17
|
+
null
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private val methods by lazy {
|
|
22
|
+
val cls = logClass ?: return@lazy null
|
|
23
|
+
mapOf(
|
|
24
|
+
"debug" to cls.getMethod("debug", String::class.java, String::class.java),
|
|
25
|
+
"info" to cls.getMethod("info", String::class.java, String::class.java),
|
|
26
|
+
"warn" to cls.getMethod("warn", String::class.java, String::class.java),
|
|
27
|
+
"error" to cls.getMethod("error", String::class.java, String::class.java),
|
|
28
|
+
)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
@JvmStatic
|
|
32
|
+
fun debug(message: String) = log("debug", message, android.util.Log.DEBUG)
|
|
33
|
+
|
|
34
|
+
@JvmStatic
|
|
35
|
+
fun info(message: String) = log("info", message, android.util.Log.INFO)
|
|
36
|
+
|
|
37
|
+
@JvmStatic
|
|
38
|
+
fun warn(message: String) = log("warn", message, android.util.Log.WARN)
|
|
39
|
+
|
|
40
|
+
@JvmStatic
|
|
41
|
+
fun error(message: String) = log("error", message, android.util.Log.ERROR)
|
|
42
|
+
|
|
43
|
+
private fun log(level: String, message: String, androidLogLevel: Int) {
|
|
44
|
+
val method = methods?.get(level)
|
|
45
|
+
if (method != null) {
|
|
46
|
+
try {
|
|
47
|
+
method.invoke(null, TAG, message)
|
|
48
|
+
return
|
|
49
|
+
} catch (_: Exception) {
|
|
50
|
+
// Fall through to android.util.Log
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
android.util.Log.println(androidLogLevel, TAG, message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
package com.sniconnect
|
|
2
2
|
|
|
3
|
-
import android.util.Log
|
|
4
3
|
import com.facebook.react.bridge.Arguments
|
|
5
4
|
import com.facebook.react.bridge.Promise
|
|
6
5
|
import com.facebook.react.bridge.ReactApplicationContext
|
|
7
6
|
import com.facebook.react.bridge.ReactMethod
|
|
8
7
|
import com.facebook.react.bridge.ReadableMap
|
|
9
8
|
import com.facebook.react.bridge.WritableMap
|
|
10
|
-
import com.facebook.react.modules.core.DeviceEventManagerModule
|
|
11
9
|
import com.facebook.react.module.annotations.ReactModule
|
|
12
10
|
import okhttp3.Call
|
|
13
11
|
import okhttp3.Callback
|
|
12
|
+
import okhttp3.ConnectionPool
|
|
13
|
+
import okhttp3.Dispatcher
|
|
14
14
|
import okhttp3.Dns
|
|
15
15
|
import okhttp3.Headers
|
|
16
16
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
@@ -21,10 +21,10 @@ import okhttp3.Response
|
|
|
21
21
|
import okhttp3.ResponseBody
|
|
22
22
|
import java.io.IOException
|
|
23
23
|
import java.net.InetAddress
|
|
24
|
-
import java.net.UnknownHostException
|
|
25
24
|
import java.util.Locale
|
|
26
25
|
import java.util.concurrent.ConcurrentHashMap
|
|
27
26
|
import java.util.concurrent.TimeUnit
|
|
27
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
28
28
|
import javax.net.ssl.HttpsURLConnection
|
|
29
29
|
|
|
30
30
|
private const val TAG = "SniConnect"
|
|
@@ -35,59 +35,49 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
35
35
|
|
|
36
36
|
companion object {
|
|
37
37
|
const val NAME = "SniConnect"
|
|
38
|
-
|
|
38
|
+
|
|
39
|
+
// Upper bound on cached OkHttpClient instances to prevent unbounded growth
|
|
40
|
+
// from JS-controlled host/IP pairs (e.g. speed-testing many endpoints).
|
|
41
|
+
private const val MAX_CLIENTS = 32
|
|
42
|
+
|
|
43
|
+
// A single dispatcher + connection pool shared across all cached clients so we
|
|
44
|
+
// don't spawn a thread pool / connection pool per (hostname, ip) pair.
|
|
45
|
+
private val sharedDispatcher = Dispatcher()
|
|
46
|
+
private val sharedConnectionPool = ConnectionPool()
|
|
39
47
|
}
|
|
40
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Cache key for OkHttpClient instances.
|
|
51
|
+
* Uses hostname:IP so different IPs for the same hostname stay isolated (accurate
|
|
52
|
+
* speed testing) while the same hostname+IP reuses connections. Timeout is NOT part
|
|
53
|
+
* of the key — it is applied per-call via `call.timeout()`.
|
|
54
|
+
*/
|
|
41
55
|
private data class ClientKey(
|
|
42
|
-
val ip: String,
|
|
43
56
|
val hostname: String,
|
|
44
|
-
val
|
|
57
|
+
val ip: String,
|
|
45
58
|
)
|
|
46
59
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
@ReactMethod
|
|
55
|
-
override fun addListener(eventType: String) {
|
|
56
|
-
listenerCount += 1
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
@ReactMethod
|
|
60
|
-
override fun removeListeners(count: Double) {
|
|
61
|
-
listenerCount -= count.toInt()
|
|
62
|
-
if (listenerCount < 0) {
|
|
63
|
-
listenerCount = 0
|
|
60
|
+
// Bounded LRU: access-ordered, evicts the eldest client when capacity is exceeded.
|
|
61
|
+
// Idle connections are reclaimed by the shared ConnectionPool's own keep-alive, so
|
|
62
|
+
// we do NOT evictAll here (that pool is shared by every client). Synchronized because
|
|
63
|
+
// LinkedHashMap is not thread-safe.
|
|
64
|
+
private val clientCache = object : LinkedHashMap<ClientKey, OkHttpClient>(16, 0.75f, true) {
|
|
65
|
+
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<ClientKey, OkHttpClient>): Boolean {
|
|
66
|
+
return size > MAX_CLIENTS
|
|
64
67
|
}
|
|
65
68
|
}
|
|
66
69
|
|
|
67
|
-
private
|
|
68
|
-
if (listenerCount > 0) {
|
|
69
|
-
val logData = Arguments.createMap().apply {
|
|
70
|
-
putString("level", level)
|
|
71
|
-
putString("message", message)
|
|
72
|
-
putDouble("timestamp", System.currentTimeMillis().toDouble())
|
|
73
|
-
}
|
|
70
|
+
private val activeCalls = ConcurrentHashMap<String, Call>()
|
|
74
71
|
|
|
75
|
-
|
|
76
|
-
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
77
|
-
?.emit(EVENT_NAME, logData)
|
|
78
|
-
}
|
|
79
|
-
}
|
|
72
|
+
override fun getName(): String = NAME
|
|
80
73
|
|
|
81
74
|
override fun request(config: ReadableMap, promise: Promise) {
|
|
82
75
|
try {
|
|
83
76
|
val requestConfig = config.toRequestConfig()
|
|
84
|
-
// Log module initialization
|
|
85
|
-
sendLogEvent("info", "SniConnect module initialized successfully")
|
|
86
77
|
performRequest(requestConfig, promise)
|
|
87
78
|
} catch (error: Exception) {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
promise.reject("SNI_REQUEST_FAILED", error.message, error)
|
|
79
|
+
SniConnectLogger.error("Config parsing failed: ${error.message}")
|
|
80
|
+
promise.reject("SNI_INVALID_CONFIG", error.message, error)
|
|
91
81
|
}
|
|
92
82
|
}
|
|
93
83
|
|
|
@@ -96,38 +86,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
96
86
|
val call = activeCalls.remove(requestId)
|
|
97
87
|
if (call != null) {
|
|
98
88
|
call.cancel()
|
|
99
|
-
|
|
100
|
-
promise.resolve(Arguments.createMap().apply {
|
|
101
|
-
putBoolean("success", true)
|
|
102
|
-
})
|
|
89
|
+
SniConnectLogger.info("Cancelled request: $requestId")
|
|
90
|
+
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
103
91
|
} else {
|
|
104
|
-
|
|
105
|
-
promise.resolve(Arguments.createMap().apply {
|
|
106
|
-
putBoolean("success", false)
|
|
107
|
-
})
|
|
92
|
+
promise.resolve(Arguments.createMap().apply { putBoolean("success", false) })
|
|
108
93
|
}
|
|
109
94
|
}
|
|
110
95
|
|
|
111
96
|
@ReactMethod
|
|
112
97
|
override fun cancelAllRequests(promise: Promise) {
|
|
113
98
|
val count = activeCalls.size
|
|
114
|
-
activeCalls.forEach { (_, call) ->
|
|
115
|
-
call.cancel()
|
|
116
|
-
}
|
|
99
|
+
activeCalls.forEach { (_, call) -> call.cancel() }
|
|
117
100
|
activeCalls.clear()
|
|
118
|
-
|
|
119
|
-
promise.resolve(Arguments.createMap().apply {
|
|
120
|
-
putBoolean("success", true)
|
|
121
|
-
})
|
|
101
|
+
SniConnectLogger.info("Cancelled $count active requests")
|
|
102
|
+
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
122
103
|
}
|
|
123
104
|
|
|
124
105
|
@ReactMethod
|
|
125
106
|
override fun clearDNSCache(promise: Promise) {
|
|
126
|
-
clientCache
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
107
|
+
synchronized(clientCache) {
|
|
108
|
+
clientCache.clear()
|
|
109
|
+
}
|
|
110
|
+
// Drop pinned-IP connections from the shared pool.
|
|
111
|
+
sharedConnectionPool.evictAll()
|
|
112
|
+
SniConnectLogger.info("DNS cache cleared")
|
|
113
|
+
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
131
114
|
}
|
|
132
115
|
|
|
133
116
|
private fun performRequest(config: RequestConfig, promise: Promise) {
|
|
@@ -136,99 +119,117 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
136
119
|
val request = buildRequest(config)
|
|
137
120
|
val call = client.newCall(request)
|
|
138
121
|
|
|
122
|
+
// Apply per-request timeout
|
|
123
|
+
call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS)
|
|
124
|
+
|
|
139
125
|
// Register the call if requestId is provided
|
|
140
126
|
config.requestId?.let { requestId ->
|
|
141
127
|
activeCalls[requestId] = call
|
|
142
|
-
sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}")
|
|
143
128
|
}
|
|
144
129
|
|
|
130
|
+
// Guard against double-settling the promise (RN hard-crashes otherwise).
|
|
131
|
+
val settled = AtomicBoolean(false)
|
|
132
|
+
|
|
145
133
|
call.enqueue(object : Callback {
|
|
146
134
|
override fun onFailure(call: Call, e: IOException) {
|
|
147
|
-
// Unregister the call
|
|
148
135
|
config.requestId?.let { activeCalls.remove(it) }
|
|
136
|
+
if (!settled.compareAndSet(false, true)) return
|
|
149
137
|
|
|
150
138
|
if (call.isCanceled()) {
|
|
151
|
-
sendLogEvent("info", "Request cancelled")
|
|
152
139
|
promise.reject("SNI_CANCELLED", "Request cancelled", null)
|
|
153
140
|
} else {
|
|
154
|
-
|
|
155
|
-
sendLogEvent("error", "Request failed: ${e.message}")
|
|
141
|
+
SniConnectLogger.error("Request failed: ${e.message}")
|
|
156
142
|
promise.reject("SNI_REQUEST_FAILED", e.message, e)
|
|
157
143
|
}
|
|
158
144
|
}
|
|
159
145
|
|
|
160
146
|
override fun onResponse(call: Call, response: Response) {
|
|
161
|
-
// Unregister the call
|
|
162
147
|
config.requestId?.let { activeCalls.remove(it) }
|
|
163
148
|
|
|
164
|
-
try {
|
|
149
|
+
val result: WritableMap = try {
|
|
165
150
|
response.use {
|
|
166
151
|
val bodyString = response.body.safeString()
|
|
167
152
|
val headerMap = headersToMap(response.headers)
|
|
168
|
-
|
|
169
|
-
val result: WritableMap = Arguments.createMap().apply {
|
|
153
|
+
Arguments.createMap().apply {
|
|
170
154
|
putString("data", bodyString)
|
|
171
155
|
putInt("status", response.code)
|
|
172
156
|
putString("statusText", response.message)
|
|
173
157
|
putMap("headers", headerMap.toWritableMap())
|
|
174
158
|
}
|
|
175
|
-
|
|
176
|
-
promise.resolve(result)
|
|
177
159
|
}
|
|
178
160
|
} catch (error: Exception) {
|
|
179
|
-
|
|
180
|
-
|
|
161
|
+
if (!settled.compareAndSet(false, true)) return
|
|
162
|
+
SniConnectLogger.error("Response processing failed: ${error.message}")
|
|
181
163
|
promise.reject("SNI_RESPONSE_FAILED", error.message, error)
|
|
164
|
+
return
|
|
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)
|
|
182
172
|
}
|
|
183
173
|
}
|
|
184
174
|
})
|
|
185
175
|
} catch (error: Exception) {
|
|
186
|
-
|
|
187
|
-
sendLogEvent("error", "Request setup failed: ${error.message}")
|
|
176
|
+
SniConnectLogger.error("Request setup failed: ${error.message}")
|
|
188
177
|
promise.reject("SNI_REQUEST_FAILED", error.message, error)
|
|
189
178
|
}
|
|
190
179
|
}
|
|
191
180
|
|
|
192
181
|
private fun getOrCreateClient(config: RequestConfig): OkHttpClient {
|
|
193
182
|
val normalizedHost = config.hostname.lowercase(Locale.US)
|
|
194
|
-
val key = ClientKey(
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
183
|
+
val key = ClientKey(normalizedHost, config.ip)
|
|
184
|
+
|
|
185
|
+
synchronized(clientCache) {
|
|
186
|
+
clientCache[key]?.let { return it }
|
|
187
|
+
|
|
188
|
+
// 60s defaults at the client level; the real deadline is the per-call timeout.
|
|
189
|
+
val defaultTimeout = 60_000L
|
|
190
|
+
|
|
191
|
+
val client = OkHttpClient.Builder()
|
|
192
|
+
.dispatcher(sharedDispatcher)
|
|
193
|
+
.connectionPool(sharedConnectionPool)
|
|
194
|
+
.connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
|
|
195
|
+
.readTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
|
|
196
|
+
.writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
|
|
197
|
+
.callTimeout(0, TimeUnit.MILLISECONDS)
|
|
198
|
+
// TLS is validated normally: cert chain via the default trust manager and
|
|
199
|
+
// hostname verification against the REAL hostname (not the pinned IP).
|
|
202
200
|
.hostnameVerifier { _, session ->
|
|
203
201
|
HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session)
|
|
204
202
|
}
|
|
205
203
|
.dns(createPinnedDns(config.ip, config.hostname))
|
|
206
204
|
.build()
|
|
205
|
+
|
|
206
|
+
clientCache[key] = client
|
|
207
|
+
return client
|
|
207
208
|
}
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
private fun createPinnedDns(ip: String, hostname: String): Dns =
|
|
211
212
|
object : Dns {
|
|
212
213
|
private val expectedHost = hostname.lowercase(Locale.US)
|
|
214
|
+
// Resolve the literal IP once up front (validated; never triggers DNS).
|
|
215
|
+
private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip)
|
|
213
216
|
|
|
214
217
|
override fun lookup(requestedHost: String): List<InetAddress> {
|
|
215
218
|
return if (requestedHost.lowercase(Locale.US) == expectedHost) {
|
|
216
|
-
listOf(
|
|
219
|
+
listOf(pinnedAddress)
|
|
217
220
|
} else {
|
|
218
221
|
Dns.SYSTEM.lookup(requestedHost)
|
|
219
222
|
}
|
|
220
223
|
}
|
|
221
224
|
}
|
|
222
225
|
|
|
226
|
+
/**
|
|
227
|
+
* Build the request. Always `https://<hostname><path>` on the implicit port 443 —
|
|
228
|
+
* `path` has been validated as relative, so scheme/host/port cannot be overridden.
|
|
229
|
+
*/
|
|
223
230
|
private fun buildRequest(config: RequestConfig): Request {
|
|
224
|
-
val
|
|
225
|
-
|
|
226
|
-
} else {
|
|
227
|
-
val prefix = if (config.path.startsWith("/")) "" else "/"
|
|
228
|
-
"https://${config.hostname}$prefix${config.path}"
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
val builder = Request.Builder().url(normalizedPath)
|
|
231
|
+
val url = "https://${config.hostname}${config.path}"
|
|
232
|
+
val builder = Request.Builder().url(url)
|
|
232
233
|
|
|
233
234
|
config.headers.forEach { (key, value) ->
|
|
234
235
|
if (!key.equals("host", ignoreCase = true)) {
|
|
@@ -237,7 +238,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
237
238
|
}
|
|
238
239
|
builder.header("Host", config.hostname)
|
|
239
240
|
|
|
240
|
-
val method = config.method
|
|
241
|
+
val method = config.method
|
|
241
242
|
val bodyContent = config.body ?: ""
|
|
242
243
|
val mediaType = config.headers.entries
|
|
243
244
|
.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
|
|
@@ -264,13 +265,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
264
265
|
}
|
|
265
266
|
|
|
266
267
|
private fun ResponseBody?.safeString(): String {
|
|
267
|
-
if (this == null)
|
|
268
|
-
return ""
|
|
269
|
-
}
|
|
268
|
+
if (this == null) return ""
|
|
270
269
|
return try {
|
|
271
270
|
this.string()
|
|
272
271
|
} catch (error: IOException) {
|
|
273
|
-
Log.e(TAG, "[SniConnect] Failed to read response body", error)
|
|
274
272
|
throw IOException("Failed to read response body", error)
|
|
275
273
|
}
|
|
276
274
|
}
|
|
@@ -283,50 +281,47 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
283
281
|
return map
|
|
284
282
|
}
|
|
285
283
|
|
|
286
|
-
private fun resolveIp(ip: String): InetAddress {
|
|
287
|
-
return try {
|
|
288
|
-
InetAddress.getByName(ip)
|
|
289
|
-
} catch (error: UnknownHostException) {
|
|
290
|
-
throw IOException("Invalid IP address: $ip", error)
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
|
|
294
284
|
private fun Map<String, String>.toWritableMap(): WritableMap {
|
|
295
285
|
return Arguments.createMap().apply {
|
|
296
|
-
forEach { (key, value) ->
|
|
297
|
-
putString(key, value)
|
|
298
|
-
}
|
|
286
|
+
forEach { (key, value) -> putString(key, value) }
|
|
299
287
|
}
|
|
300
288
|
}
|
|
301
289
|
|
|
302
290
|
private fun ReadableMap.toRequestConfig(): RequestConfig {
|
|
303
291
|
val headersMap = if (hasKey("headers") && !isNull("headers")) {
|
|
304
|
-
|
|
305
|
-
headersReadable?.toHashMap()
|
|
292
|
+
getMap("headers")?.toHashMap()
|
|
306
293
|
?.mapValues { (_, value) -> value?.toString() ?: "" }
|
|
307
294
|
?: emptyMap()
|
|
308
295
|
} else {
|
|
309
296
|
emptyMap()
|
|
310
297
|
}
|
|
311
298
|
|
|
312
|
-
val timeoutMillis = if (hasKey("timeout")) {
|
|
313
|
-
|
|
299
|
+
val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
|
|
300
|
+
getDouble("timeout").toLong().coerceAtLeast(1L)
|
|
314
301
|
} else {
|
|
315
302
|
30_000L
|
|
316
303
|
}
|
|
317
304
|
|
|
318
|
-
val requestId = if (hasKey("requestId") && !isNull("requestId"))
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
305
|
+
val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null
|
|
306
|
+
|
|
307
|
+
val ip = getString("ip") ?: throw IllegalArgumentException("ip is required")
|
|
308
|
+
val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required")
|
|
309
|
+
val method = getString("method") ?: "GET"
|
|
310
|
+
val path = getString("path") ?: "/"
|
|
311
|
+
|
|
312
|
+
// Validate every caller-controlled field at the boundary.
|
|
313
|
+
SniConnectValidation.validatePublicIp(ip)
|
|
314
|
+
SniConnectValidation.validateHostname(hostname)
|
|
315
|
+
SniConnectValidation.validateHeaders(headersMap)
|
|
316
|
+
val normalizedMethod = SniConnectValidation.normalizeMethod(method)
|
|
317
|
+
val normalizedPath = SniConnectValidation.normalizePath(path)
|
|
323
318
|
|
|
324
319
|
return RequestConfig(
|
|
325
320
|
requestId = requestId,
|
|
326
|
-
ip =
|
|
327
|
-
hostname =
|
|
328
|
-
method =
|
|
329
|
-
path =
|
|
321
|
+
ip = ip,
|
|
322
|
+
hostname = hostname,
|
|
323
|
+
method = normalizedMethod,
|
|
324
|
+
path = normalizedPath,
|
|
330
325
|
headers = headersMap,
|
|
331
326
|
body = if (hasKey("body") && !isNull("body")) getString("body") else null,
|
|
332
327
|
timeoutMillis = timeoutMillis,
|