@onekeyfe/react-native-sni-connect 1.1.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 +104 -123
- 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 +88 -113
- 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,65 +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
|
|
|
41
49
|
/**
|
|
42
|
-
* Cache key for OkHttpClient instances
|
|
43
|
-
* Uses hostname:IP
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* Note: timeout is NOT part of the key to allow connection reuse across different timeout values
|
|
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()`.
|
|
47
54
|
*/
|
|
48
55
|
private data class ClientKey(
|
|
49
56
|
val hostname: String,
|
|
50
57
|
val ip: String,
|
|
51
58
|
)
|
|
52
59
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
@ReactMethod
|
|
61
|
-
override fun addListener(eventType: String) {
|
|
62
|
-
listenerCount += 1
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
@ReactMethod
|
|
66
|
-
override fun removeListeners(count: Double) {
|
|
67
|
-
listenerCount -= count.toInt()
|
|
68
|
-
if (listenerCount < 0) {
|
|
69
|
-
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
|
|
70
67
|
}
|
|
71
68
|
}
|
|
72
69
|
|
|
73
|
-
private
|
|
74
|
-
if (listenerCount > 0) {
|
|
75
|
-
val logData = Arguments.createMap().apply {
|
|
76
|
-
putString("level", level)
|
|
77
|
-
putString("message", message)
|
|
78
|
-
putDouble("timestamp", System.currentTimeMillis().toDouble())
|
|
79
|
-
}
|
|
70
|
+
private val activeCalls = ConcurrentHashMap<String, Call>()
|
|
80
71
|
|
|
81
|
-
|
|
82
|
-
.getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java)
|
|
83
|
-
?.emit(EVENT_NAME, logData)
|
|
84
|
-
}
|
|
85
|
-
}
|
|
72
|
+
override fun getName(): String = NAME
|
|
86
73
|
|
|
87
74
|
override fun request(config: ReadableMap, promise: Promise) {
|
|
88
75
|
try {
|
|
89
76
|
val requestConfig = config.toRequestConfig()
|
|
90
|
-
// Log module initialization
|
|
91
|
-
sendLogEvent("info", "SniConnect module initialized successfully")
|
|
92
77
|
performRequest(requestConfig, promise)
|
|
93
78
|
} catch (error: Exception) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
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)
|
|
97
81
|
}
|
|
98
82
|
}
|
|
99
83
|
|
|
@@ -102,38 +86,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
102
86
|
val call = activeCalls.remove(requestId)
|
|
103
87
|
if (call != null) {
|
|
104
88
|
call.cancel()
|
|
105
|
-
|
|
106
|
-
promise.resolve(Arguments.createMap().apply {
|
|
107
|
-
putBoolean("success", true)
|
|
108
|
-
})
|
|
89
|
+
SniConnectLogger.info("Cancelled request: $requestId")
|
|
90
|
+
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
109
91
|
} else {
|
|
110
|
-
|
|
111
|
-
promise.resolve(Arguments.createMap().apply {
|
|
112
|
-
putBoolean("success", false)
|
|
113
|
-
})
|
|
92
|
+
promise.resolve(Arguments.createMap().apply { putBoolean("success", false) })
|
|
114
93
|
}
|
|
115
94
|
}
|
|
116
95
|
|
|
117
96
|
@ReactMethod
|
|
118
97
|
override fun cancelAllRequests(promise: Promise) {
|
|
119
98
|
val count = activeCalls.size
|
|
120
|
-
activeCalls.forEach { (_, call) ->
|
|
121
|
-
call.cancel()
|
|
122
|
-
}
|
|
99
|
+
activeCalls.forEach { (_, call) -> call.cancel() }
|
|
123
100
|
activeCalls.clear()
|
|
124
|
-
|
|
125
|
-
promise.resolve(Arguments.createMap().apply {
|
|
126
|
-
putBoolean("success", true)
|
|
127
|
-
})
|
|
101
|
+
SniConnectLogger.info("Cancelled $count active requests")
|
|
102
|
+
promise.resolve(Arguments.createMap().apply { putBoolean("success", true) })
|
|
128
103
|
}
|
|
129
104
|
|
|
130
105
|
@ReactMethod
|
|
131
106
|
override fun clearDNSCache(promise: Promise) {
|
|
132
|
-
clientCache
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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) })
|
|
137
114
|
}
|
|
138
115
|
|
|
139
116
|
private fun performRequest(config: RequestConfig, promise: Promise) {
|
|
@@ -148,52 +125,55 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
148
125
|
// Register the call if requestId is provided
|
|
149
126
|
config.requestId?.let { requestId ->
|
|
150
127
|
activeCalls[requestId] = call
|
|
151
|
-
sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}")
|
|
152
128
|
}
|
|
153
129
|
|
|
130
|
+
// Guard against double-settling the promise (RN hard-crashes otherwise).
|
|
131
|
+
val settled = AtomicBoolean(false)
|
|
132
|
+
|
|
154
133
|
call.enqueue(object : Callback {
|
|
155
134
|
override fun onFailure(call: Call, e: IOException) {
|
|
156
|
-
// Unregister the call
|
|
157
135
|
config.requestId?.let { activeCalls.remove(it) }
|
|
136
|
+
if (!settled.compareAndSet(false, true)) return
|
|
158
137
|
|
|
159
138
|
if (call.isCanceled()) {
|
|
160
|
-
sendLogEvent("info", "Request cancelled")
|
|
161
139
|
promise.reject("SNI_CANCELLED", "Request cancelled", null)
|
|
162
140
|
} else {
|
|
163
|
-
|
|
164
|
-
sendLogEvent("error", "Request failed: ${e.message}")
|
|
141
|
+
SniConnectLogger.error("Request failed: ${e.message}")
|
|
165
142
|
promise.reject("SNI_REQUEST_FAILED", e.message, e)
|
|
166
143
|
}
|
|
167
144
|
}
|
|
168
145
|
|
|
169
146
|
override fun onResponse(call: Call, response: Response) {
|
|
170
|
-
// Unregister the call
|
|
171
147
|
config.requestId?.let { activeCalls.remove(it) }
|
|
172
148
|
|
|
173
|
-
try {
|
|
149
|
+
val result: WritableMap = try {
|
|
174
150
|
response.use {
|
|
175
151
|
val bodyString = response.body.safeString()
|
|
176
152
|
val headerMap = headersToMap(response.headers)
|
|
177
|
-
|
|
178
|
-
val result: WritableMap = Arguments.createMap().apply {
|
|
153
|
+
Arguments.createMap().apply {
|
|
179
154
|
putString("data", bodyString)
|
|
180
155
|
putInt("status", response.code)
|
|
181
156
|
putString("statusText", response.message)
|
|
182
157
|
putMap("headers", headerMap.toWritableMap())
|
|
183
158
|
}
|
|
184
|
-
|
|
185
|
-
promise.resolve(result)
|
|
186
159
|
}
|
|
187
160
|
} catch (error: Exception) {
|
|
188
|
-
|
|
189
|
-
|
|
161
|
+
if (!settled.compareAndSet(false, true)) return
|
|
162
|
+
SniConnectLogger.error("Response processing failed: ${error.message}")
|
|
190
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)
|
|
191
172
|
}
|
|
192
173
|
}
|
|
193
174
|
})
|
|
194
175
|
} catch (error: Exception) {
|
|
195
|
-
|
|
196
|
-
sendLogEvent("error", "Request setup failed: ${error.message}")
|
|
176
|
+
SniConnectLogger.error("Request setup failed: ${error.message}")
|
|
197
177
|
promise.reject("SNI_REQUEST_FAILED", error.message, error)
|
|
198
178
|
}
|
|
199
179
|
}
|
|
@@ -202,47 +182,54 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
202
182
|
val normalizedHost = config.hostname.lowercase(Locale.US)
|
|
203
183
|
val key = ClientKey(normalizedHost, config.ip)
|
|
204
184
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
//
|
|
209
|
-
val defaultTimeout = 60_000L
|
|
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
|
|
210
190
|
|
|
211
|
-
OkHttpClient.Builder()
|
|
191
|
+
val client = OkHttpClient.Builder()
|
|
192
|
+
.dispatcher(sharedDispatcher)
|
|
193
|
+
.connectionPool(sharedConnectionPool)
|
|
212
194
|
.connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
|
|
213
195
|
.readTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
|
|
214
196
|
.writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS)
|
|
215
|
-
.callTimeout(0, 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).
|
|
216
200
|
.hostnameVerifier { _, session ->
|
|
217
201
|
HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session)
|
|
218
202
|
}
|
|
219
203
|
.dns(createPinnedDns(config.ip, config.hostname))
|
|
220
204
|
.build()
|
|
205
|
+
|
|
206
|
+
clientCache[key] = client
|
|
207
|
+
return client
|
|
221
208
|
}
|
|
222
209
|
}
|
|
223
210
|
|
|
224
211
|
private fun createPinnedDns(ip: String, hostname: String): Dns =
|
|
225
212
|
object : Dns {
|
|
226
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)
|
|
227
216
|
|
|
228
217
|
override fun lookup(requestedHost: String): List<InetAddress> {
|
|
229
218
|
return if (requestedHost.lowercase(Locale.US) == expectedHost) {
|
|
230
|
-
listOf(
|
|
219
|
+
listOf(pinnedAddress)
|
|
231
220
|
} else {
|
|
232
221
|
Dns.SYSTEM.lookup(requestedHost)
|
|
233
222
|
}
|
|
234
223
|
}
|
|
235
224
|
}
|
|
236
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
|
+
*/
|
|
237
230
|
private fun buildRequest(config: RequestConfig): Request {
|
|
238
|
-
val
|
|
239
|
-
|
|
240
|
-
} else {
|
|
241
|
-
val prefix = if (config.path.startsWith("/")) "" else "/"
|
|
242
|
-
"https://${config.hostname}$prefix${config.path}"
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
val builder = Request.Builder().url(normalizedPath)
|
|
231
|
+
val url = "https://${config.hostname}${config.path}"
|
|
232
|
+
val builder = Request.Builder().url(url)
|
|
246
233
|
|
|
247
234
|
config.headers.forEach { (key, value) ->
|
|
248
235
|
if (!key.equals("host", ignoreCase = true)) {
|
|
@@ -251,7 +238,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
251
238
|
}
|
|
252
239
|
builder.header("Host", config.hostname)
|
|
253
240
|
|
|
254
|
-
val method = config.method
|
|
241
|
+
val method = config.method
|
|
255
242
|
val bodyContent = config.body ?: ""
|
|
256
243
|
val mediaType = config.headers.entries
|
|
257
244
|
.firstOrNull { it.key.equals("Content-Type", ignoreCase = true) }
|
|
@@ -278,13 +265,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
278
265
|
}
|
|
279
266
|
|
|
280
267
|
private fun ResponseBody?.safeString(): String {
|
|
281
|
-
if (this == null)
|
|
282
|
-
return ""
|
|
283
|
-
}
|
|
268
|
+
if (this == null) return ""
|
|
284
269
|
return try {
|
|
285
270
|
this.string()
|
|
286
271
|
} catch (error: IOException) {
|
|
287
|
-
Log.e(TAG, "[SniConnect] Failed to read response body", error)
|
|
288
272
|
throw IOException("Failed to read response body", error)
|
|
289
273
|
}
|
|
290
274
|
}
|
|
@@ -297,50 +281,47 @@ class SniConnectModule(reactContext: ReactApplicationContext) :
|
|
|
297
281
|
return map
|
|
298
282
|
}
|
|
299
283
|
|
|
300
|
-
private fun resolveIp(ip: String): InetAddress {
|
|
301
|
-
return try {
|
|
302
|
-
InetAddress.getByName(ip)
|
|
303
|
-
} catch (error: UnknownHostException) {
|
|
304
|
-
throw IOException("Invalid IP address: $ip", error)
|
|
305
|
-
}
|
|
306
|
-
}
|
|
307
|
-
|
|
308
284
|
private fun Map<String, String>.toWritableMap(): WritableMap {
|
|
309
285
|
return Arguments.createMap().apply {
|
|
310
|
-
forEach { (key, value) ->
|
|
311
|
-
putString(key, value)
|
|
312
|
-
}
|
|
286
|
+
forEach { (key, value) -> putString(key, value) }
|
|
313
287
|
}
|
|
314
288
|
}
|
|
315
289
|
|
|
316
290
|
private fun ReadableMap.toRequestConfig(): RequestConfig {
|
|
317
291
|
val headersMap = if (hasKey("headers") && !isNull("headers")) {
|
|
318
|
-
|
|
319
|
-
headersReadable?.toHashMap()
|
|
292
|
+
getMap("headers")?.toHashMap()
|
|
320
293
|
?.mapValues { (_, value) -> value?.toString() ?: "" }
|
|
321
294
|
?: emptyMap()
|
|
322
295
|
} else {
|
|
323
296
|
emptyMap()
|
|
324
297
|
}
|
|
325
298
|
|
|
326
|
-
val timeoutMillis = if (hasKey("timeout")) {
|
|
327
|
-
|
|
299
|
+
val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) {
|
|
300
|
+
getDouble("timeout").toLong().coerceAtLeast(1L)
|
|
328
301
|
} else {
|
|
329
302
|
30_000L
|
|
330
303
|
}
|
|
331
304
|
|
|
332
|
-
val requestId = if (hasKey("requestId") && !isNull("requestId"))
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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)
|
|
337
318
|
|
|
338
319
|
return RequestConfig(
|
|
339
320
|
requestId = requestId,
|
|
340
|
-
ip =
|
|
341
|
-
hostname =
|
|
342
|
-
method =
|
|
343
|
-
path =
|
|
321
|
+
ip = ip,
|
|
322
|
+
hostname = hostname,
|
|
323
|
+
method = normalizedMethod,
|
|
324
|
+
path = normalizedPath,
|
|
344
325
|
headers = headersMap,
|
|
345
326
|
body = if (hasKey("body") && !isNull("body")) getString("body") else null,
|
|
346
327
|
timeoutMillis = timeoutMillis,
|