From 7d55a098a98d573000032d3b3ab63a1cd73095d2 Mon Sep 17 00:00:00 2001 From: huhuanming Date: Fri, 12 Jun 2026 23:58:45 +0800 Subject: [PATCH 1/2] feat: add @onekeyfe/react-native-sni-connect module (3.0.66) Sync react-native-sni-connect from OneKeyHQ/react-native-sni-connect into native-modules, aligned to the shared 3.0.66 version. Bumps the EMASCurl pod dependency to 1.5.5 (previously applied as an app-monorepo patch). --- .../react-native-sni-connect/README.md | 37 ++ .../SniConnect.podspec | 36 ++ .../android/build.gradle | 89 ++++ .../android/gradle.properties | 5 + .../android/src/main/AndroidManifest.xml | 2 + .../java/com/sniconnect/SniConnectModule.kt | 360 +++++++++++++ .../java/com/sniconnect/SniConnectPackage.kt | 31 ++ .../react-native-sni-connect/babel.config.js | 12 + .../ios/SniConnect-Bridging-Header.h | 13 + .../ios/SniConnect.mm | 141 +++++ .../ios/SniConnect.swift | 185 +++++++ .../ios/SniConnectClient.swift | 497 ++++++++++++++++++ .../react-native-sni-connect/package.json | 157 ++++++ .../scripts/block-ips.sh | 44 ++ .../scripts/sni-request.js | 74 +++ .../scripts/verify-sni.sh | 84 +++ .../src/@types/react-native-codegen.d.ts | 8 + .../src/NativeSniConnect.ts | 70 +++ .../src/__tests__/index.test.ts | 435 +++++++++++++++ .../react-native-sni-connect/src/index.tsx | 46 ++ .../tsconfig.build.json | 4 + .../react-native-sni-connect/tsconfig.json | 30 ++ .../react-native-sni-connect/turbo.json | 42 ++ yarn.lock | 33 ++ 24 files changed, 2435 insertions(+) create mode 100644 native-modules/react-native-sni-connect/README.md create mode 100644 native-modules/react-native-sni-connect/SniConnect.podspec create mode 100644 native-modules/react-native-sni-connect/android/build.gradle create mode 100644 native-modules/react-native-sni-connect/android/gradle.properties create mode 100644 native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt create mode 100644 native-modules/react-native-sni-connect/babel.config.js create mode 100644 native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h create mode 100644 native-modules/react-native-sni-connect/ios/SniConnect.mm create mode 100644 native-modules/react-native-sni-connect/ios/SniConnect.swift create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectClient.swift create mode 100644 native-modules/react-native-sni-connect/package.json create mode 100755 native-modules/react-native-sni-connect/scripts/block-ips.sh create mode 100644 native-modules/react-native-sni-connect/scripts/sni-request.js create mode 100755 native-modules/react-native-sni-connect/scripts/verify-sni.sh create mode 100644 native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts create mode 100644 native-modules/react-native-sni-connect/src/NativeSniConnect.ts create mode 100644 native-modules/react-native-sni-connect/src/__tests__/index.test.ts create mode 100644 native-modules/react-native-sni-connect/src/index.tsx create mode 100644 native-modules/react-native-sni-connect/tsconfig.build.json create mode 100644 native-modules/react-native-sni-connect/tsconfig.json create mode 100644 native-modules/react-native-sni-connect/turbo.json diff --git a/native-modules/react-native-sni-connect/README.md b/native-modules/react-native-sni-connect/README.md new file mode 100644 index 00000000..ae6ef919 --- /dev/null +++ b/native-modules/react-native-sni-connect/README.md @@ -0,0 +1,37 @@ +# react-native-sni-connect + +onekey sni http client + +## Installation + + +```sh +npm install react-native-sni-connect +``` + + +## Usage + + +```js +import { multiply } from 'react-native-sni-connect'; + +// ... + +const result = multiply(3, 7); +``` + + +## Contributing + +- [Development workflow](CONTRIBUTING.md#development-workflow) +- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) +- [Code of conduct](CODE_OF_CONDUCT.md) + +## License + +MIT + +--- + +Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) diff --git a/native-modules/react-native-sni-connect/SniConnect.podspec b/native-modules/react-native-sni-connect/SniConnect.podspec new file mode 100644 index 00000000..cd05d30f --- /dev/null +++ b/native-modules/react-native-sni-connect/SniConnect.podspec @@ -0,0 +1,36 @@ +require "json" + +package = JSON.parse(File.read(File.join(__dir__, "package.json"))) + +Pod::Spec.new do |s| + s.name = "SniConnect" + s.version = package["version"] + s.summary = package["description"] + s.homepage = package["homepage"] + s.license = package["license"] + s.authors = package["author"] + + s.platforms = { :ios => min_ios_version_supported } + s.source = { :git => "https://github.com/OneKeyHQ/react-native-sni-connect.git", :tag => "#{s.version}" } + + s.static_framework = true + + s.source_files = [ + "ios/**/*.{swift}", + "ios/**/*.{m,mm}" + ] + + s.dependency 'React-Core' + s.dependency 'React-jsi' + s.dependency 'React-callinvoker' + s.dependency 'React-Codegen' + s.dependency 'EMASCurl', '1.5.5' + + s.pod_target_xcconfig = { + "HEADER_SEARCH_PATHS" => "\"$(PODS_ROOT)/Headers/Private/React-Core\"", + "DEFINES_MODULE" => "YES" + } + + + install_modules_dependencies(s) +end diff --git a/native-modules/react-native-sni-connect/android/build.gradle b/native-modules/react-native-sni-connect/android/build.gradle new file mode 100644 index 00000000..90287b90 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/build.gradle @@ -0,0 +1,89 @@ +buildscript { + ext.getExtOrDefault = {name -> + return rootProject.ext.has(name) ? rootProject.ext.get(name) : project.properties['SniConnect_' + name] + } + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath "com.android.tools.build:gradle:8.7.2" + // noinspection DifferentKotlinGradleVersion + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:${getExtOrDefault('kotlinVersion')}" + } +} + + +apply plugin: "com.android.library" +apply plugin: "kotlin-android" +apply plugin: "com.facebook.react" + +def getExtOrIntegerDefault(name) { + return rootProject.ext.has(name) ? rootProject.ext.get(name) : (project.properties["SniConnect_" + name]).toInteger() +} + +android { + namespace "com.sniconnect" + + compileSdkVersion getExtOrIntegerDefault("compileSdkVersion") + + defaultConfig { + minSdkVersion getExtOrIntegerDefault("minSdkVersion") + targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") + } + + packagingOptions { + excludes = [ + "META-INF", + "META-INF/**", + "**/libc++_shared.so", + "**/libfbjni.so", + "**/libjsi.so", + "**/libfolly_json.so", + "**/libfolly_runtime.so", + "**/libglog.so", + "**/libhermes.so", + "**/libhermes-executor-debug.so", + "**/libhermes_executor.so", + "**/libreactnative.so", + "**/libreactnativejni.so", + "**/libturbomodulejsijni.so", + "**/libreact_nativemodule_core.so", + "**/libjscexecutor.so" + ] + } + + buildFeatures { + buildConfig true + } + + buildTypes { + release { + minifyEnabled false + } + } + + lintOptions { + disable "GradleCompatible" + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +repositories { + mavenCentral() + google() +} + +def kotlin_version = getExtOrDefault("kotlinVersion") + +dependencies { + implementation "com.facebook.react:react-android" + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation "com.squareup.okhttp3:okhttp:4.12.0" +} diff --git a/native-modules/react-native-sni-connect/android/gradle.properties b/native-modules/react-native-sni-connect/android/gradle.properties new file mode 100644 index 00000000..6480e036 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/gradle.properties @@ -0,0 +1,5 @@ +SniConnect_kotlinVersion=2.0.21 +SniConnect_minSdkVersion=24 +SniConnect_targetSdkVersion=34 +SniConnect_compileSdkVersion=35 +SniConnect_ndkVersion=27.1.12297006 diff --git a/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml b/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a2f47b60 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt new file mode 100644 index 00000000..284f64f9 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -0,0 +1,360 @@ +package com.sniconnect + +import android.util.Log +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableMap +import com.facebook.react.modules.core.DeviceEventManagerModule +import com.facebook.react.module.annotations.ReactModule +import okhttp3.Call +import okhttp3.Callback +import okhttp3.Dns +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.ResponseBody +import java.io.IOException +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.Locale +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.TimeUnit +import javax.net.ssl.HttpsURLConnection + +private const val TAG = "SniConnect" + +@ReactModule(name = SniConnectModule.NAME) +class SniConnectModule(reactContext: ReactApplicationContext) : + NativeSniConnectSpec(reactContext) { + + companion object { + const val NAME = "SniConnect" + private const val EVENT_NAME = "SniConnectLog" + } + + /** + * Cache key for OkHttpClient instances + * Uses hostname:IP combination to ensure: + * - Different IPs for the same hostname are isolated (accurate speed testing) + * - Same hostname+IP combination can reuse connections (performance optimization) + * Note: timeout is NOT part of the key to allow connection reuse across different timeout values + */ + private data class ClientKey( + val hostname: String, + val ip: String, + ) + + private val clientCache = ConcurrentHashMap() + private val activeCalls = ConcurrentHashMap() + private var listenerCount = 0 + + override fun getName(): String = NAME + + // Event emitter methods required for NativeEventEmitter + @ReactMethod + override fun addListener(eventType: String) { + listenerCount += 1 + } + + @ReactMethod + override fun removeListeners(count: Double) { + listenerCount -= count.toInt() + if (listenerCount < 0) { + listenerCount = 0 + } + } + + private fun sendLogEvent(level: String, message: String) { + if (listenerCount > 0) { + val logData = Arguments.createMap().apply { + putString("level", level) + putString("message", message) + putDouble("timestamp", System.currentTimeMillis().toDouble()) + } + + reactApplicationContext + .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) + ?.emit(EVENT_NAME, logData) + } + } + + override fun request(config: ReadableMap, promise: Promise) { + try { + val requestConfig = config.toRequestConfig() + // Log module initialization + sendLogEvent("info", "SniConnect module initialized successfully") + performRequest(requestConfig, promise) + } catch (error: Exception) { + Log.e(TAG, "[SniConnect] Request failed", error) + sendLogEvent("error", "Config parsing failed: ${error.message}") + promise.reject("SNI_REQUEST_FAILED", error.message, error) + } + } + + @ReactMethod + override fun cancelRequest(requestId: String, promise: Promise) { + val call = activeCalls.remove(requestId) + if (call != null) { + call.cancel() + sendLogEvent("info", "Cancelled request: $requestId") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", true) + }) + } else { + sendLogEvent("info", "Request not found: $requestId") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", false) + }) + } + } + + @ReactMethod + override fun cancelAllRequests(promise: Promise) { + val count = activeCalls.size + activeCalls.forEach { (_, call) -> + call.cancel() + } + activeCalls.clear() + sendLogEvent("info", "Cancelled $count active requests") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", true) + }) + } + + @ReactMethod + override fun clearDNSCache(promise: Promise) { + clientCache.clear() + sendLogEvent("info", "DNS cache cleared") + promise.resolve(Arguments.createMap().apply { + putBoolean("success", true) + }) + } + + private fun performRequest(config: RequestConfig, promise: Promise) { + try { + val client = getOrCreateClient(config) + val request = buildRequest(config) + val call = client.newCall(request) + + // Apply per-request timeout + call.timeout().timeout(config.timeoutMillis, TimeUnit.MILLISECONDS) + + // Register the call if requestId is provided + config.requestId?.let { requestId -> + activeCalls[requestId] = call + sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}") + } + + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + // Unregister the call + config.requestId?.let { activeCalls.remove(it) } + + if (call.isCanceled()) { + sendLogEvent("info", "Request cancelled") + promise.reject("SNI_CANCELLED", "Request cancelled", null) + } else { + Log.e(TAG, "[SniConnect] Request failed", e) + sendLogEvent("error", "Request failed: ${e.message}") + promise.reject("SNI_REQUEST_FAILED", e.message, e) + } + } + + override fun onResponse(call: Call, response: Response) { + // Unregister the call + config.requestId?.let { activeCalls.remove(it) } + + try { + response.use { + val bodyString = response.body.safeString() + val headerMap = headersToMap(response.headers) + + val result: WritableMap = Arguments.createMap().apply { + putString("data", bodyString) + putInt("status", response.code) + putString("statusText", response.message) + putMap("headers", headerMap.toWritableMap()) + } + + promise.resolve(result) + } + } catch (error: Exception) { + Log.e(TAG, "[SniConnect] Response processing failed", error) + sendLogEvent("error", "Response processing failed: ${error.message}") + promise.reject("SNI_RESPONSE_FAILED", error.message, error) + } + } + }) + } catch (error: Exception) { + Log.e(TAG, "[SniConnect] Request setup failed", error) + sendLogEvent("error", "Request setup failed: ${error.message}") + promise.reject("SNI_REQUEST_FAILED", error.message, error) + } + } + + private fun getOrCreateClient(config: RequestConfig): OkHttpClient { + val normalizedHost = config.hostname.lowercase(Locale.US) + val key = ClientKey(normalizedHost, config.ip) + + return clientCache.getOrPut(key) { + // Note: timeout is not part of the cache key to allow connection reuse + // Use reasonable default timeouts for the client + // Per-request timeout is applied via call.timeout() in performRequest + val defaultTimeout = 60_000L // 60 seconds + + OkHttpClient.Builder() + .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .callTimeout(0, TimeUnit.MILLISECONDS) // Disable client-level call timeout + .hostnameVerifier { _, session -> + HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session) + } + .dns(createPinnedDns(config.ip, config.hostname)) + .build() + } + } + + private fun createPinnedDns(ip: String, hostname: String): Dns = + object : Dns { + private val expectedHost = hostname.lowercase(Locale.US) + + override fun lookup(requestedHost: String): List { + return if (requestedHost.lowercase(Locale.US) == expectedHost) { + listOf(resolveIp(ip)) + } else { + Dns.SYSTEM.lookup(requestedHost) + } + } + } + + private fun buildRequest(config: RequestConfig): Request { + val normalizedPath = if (config.path.startsWith("http")) { + config.path + } else { + val prefix = if (config.path.startsWith("/")) "" else "/" + "https://${config.hostname}$prefix${config.path}" + } + + val builder = Request.Builder().url(normalizedPath) + + config.headers.forEach { (key, value) -> + if (!key.equals("host", ignoreCase = true)) { + builder.addHeader(key, value) + } + } + builder.header("Host", config.hostname) + + val method = config.method.uppercase(Locale.US) + val bodyContent = config.body ?: "" + val mediaType = config.headers.entries + .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) } + ?.value + ?.toMediaTypeOrNull() + ?: "application/json; charset=utf-8".toMediaTypeOrNull() + + when (method) { + "GET" -> builder.get() + "HEAD" -> builder.head() + else -> { + val requestBody = bodyContent.toRequestBody(mediaType) + when (method) { + "POST" -> builder.post(requestBody) + "PUT" -> builder.put(requestBody) + "PATCH" -> builder.patch(requestBody) + "DELETE" -> builder.delete(requestBody) + else -> builder.method(method, requestBody) + } + } + } + + return builder.build() + } + + private fun ResponseBody?.safeString(): String { + if (this == null) { + return "" + } + return try { + this.string() + } catch (error: IOException) { + Log.e(TAG, "[SniConnect] Failed to read response body", error) + throw IOException("Failed to read response body", error) + } + } + + private fun headersToMap(headers: Headers): Map { + val map = mutableMapOf() + for (name in headers.names()) { + map[name] = headers[name] ?: "" + } + return map + } + + private fun resolveIp(ip: String): InetAddress { + return try { + InetAddress.getByName(ip) + } catch (error: UnknownHostException) { + throw IOException("Invalid IP address: $ip", error) + } + } + + private fun Map.toWritableMap(): WritableMap { + return Arguments.createMap().apply { + forEach { (key, value) -> + putString(key, value) + } + } + } + + private fun ReadableMap.toRequestConfig(): RequestConfig { + val headersMap = if (hasKey("headers") && !isNull("headers")) { + val headersReadable = getMap("headers") + headersReadable?.toHashMap() + ?.mapValues { (_, value) -> value?.toString() ?: "" } + ?: emptyMap() + } else { + emptyMap() + } + + val timeoutMillis = if (hasKey("timeout")) { + (getDouble("timeout") * 1.0).toLong() + } else { + 30_000L + } + + val requestId = if (hasKey("requestId") && !isNull("requestId")) { + getString("requestId") + } else { + null + } + + return RequestConfig( + requestId = requestId, + ip = getString("ip") ?: throw IllegalArgumentException("ip is required"), + hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required"), + method = getString("method") ?: "GET", + path = getString("path") ?: "/", + headers = headersMap, + body = if (hasKey("body") && !isNull("body")) getString("body") else null, + timeoutMillis = timeoutMillis, + ) + } + + private data class RequestConfig( + val requestId: String?, + val ip: String, + val hostname: String, + val method: String, + val path: String, + val headers: Map, + val body: String?, + val timeoutMillis: Long, + ) +} diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt new file mode 100644 index 00000000..7ab45e26 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectPackage.kt @@ -0,0 +1,31 @@ +package com.sniconnect + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.uimanager.ViewManager + +class SniConnectPackage : TurboReactPackage() { + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? = + if (name == SniConnectModule.NAME) SniConnectModule(reactContext) else null + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider = + ReactModuleInfoProvider { + mapOf( + SniConnectModule.NAME to ReactModuleInfo( + SniConnectModule.NAME, + SniConnectModule.NAME, + false, + false, + true, + false, + true + ) + ) + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/native-modules/react-native-sni-connect/babel.config.js b/native-modules/react-native-sni-connect/babel.config.js new file mode 100644 index 00000000..0c05fd69 --- /dev/null +++ b/native-modules/react-native-sni-connect/babel.config.js @@ -0,0 +1,12 @@ +module.exports = { + overrides: [ + { + exclude: /\/node_modules\//, + presets: ['module:react-native-builder-bob/babel-preset'], + }, + { + include: /\/node_modules\//, + presets: ['module:@react-native/babel-preset'], + }, + ], +}; diff --git a/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h new file mode 100644 index 00000000..0a583fa9 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h @@ -0,0 +1,13 @@ +// +// SniConnect-Bridging-Header.h +// SniConnect +// +// Used to import Objective-C headers that should be visible to Swift. +// + +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.mm b/native-modules/react-native-sni-connect/ios/SniConnect.mm new file mode 100644 index 00000000..336c6545 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm @@ -0,0 +1,141 @@ +#import +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif + +// Forward declaration of the Swift implementation +@interface SniConnectImpl : NSObject +- (instancetype)initWithEventSender:(id)eventSender; +- (void)request:(NSDictionary *)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)cancelRequest:(NSString *)requestId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)cancelAllRequests:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +- (void)clearDNSCache:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject; +@end + +@interface SniConnect : RCTEventEmitter +#ifdef RCT_NEW_ARCH_ENABLED + +#else + +#endif +@end + +@implementation SniConnect { + SniConnectImpl *_implementation; + BOOL _hasListeners; +} + +RCT_EXPORT_MODULE(SniConnect) + +// Expose hasListeners as a property for Swift access +- (BOOL)hasListeners { + return _hasListeners; +} + ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +- (instancetype)init { + if (self = [super init]) { + _implementation = [[SniConnectImpl alloc] initWithEventSender:self]; + _hasListeners = NO; + } + return self; +} + +// Event emitter methods +- (NSArray *)supportedEvents { + return @[@"SniConnectLog"]; +} + +- (void)startObserving { + _hasListeners = YES; +} + +- (void)stopObserving { + _hasListeners = NO; +} + +// Method to send log event to JS +- (void)sendLogEvent:(NSDictionary *)logData { + if (_hasListeners) { + [self sendEventWithName:@"SniConnectLog" body:logData]; + } +} + +#ifdef RCT_NEW_ARCH_ENABLED +// TurboModule interface implementation +- (void)request:(JS::NativeSniConnect::SniConnectRequest &)config + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + // Convert Codegen struct to NSDictionary for Swift implementation + NSDictionary *configDict = @{ + @"ip": config.ip(), + @"hostname": config.hostname(), + @"method": config.method(), + @"path": config.path(), + @"headers": config.headers(), + @"body": config.body() ?: [NSNull null], + @"timeout": @(config.timeout()) + }; + + [_implementation request:configDict resolve:resolve reject:reject]; +} + +- (void)cancelRequest:(NSString *)requestId + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation cancelRequest:requestId resolve:resolve reject:reject]; +} + +- (void)cancelAllRequests:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation cancelAllRequests:resolve reject:reject]; +} + +- (void)clearDNSCache:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + [_implementation clearDNSCache:resolve reject:reject]; +} + +- (std::shared_ptr)getTurboModule: + (const facebook::react::ObjCTurboModule::InitParams &)params +{ + return std::make_shared(params); +} +#else +// Bridge module interface implementation +RCT_EXPORT_METHOD(request:(NSDictionary *)config + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [_implementation request:config resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(cancelRequest:(NSString *)requestId + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [_implementation cancelRequest:requestId resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(cancelAllRequests:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) { + [_implementation cancelAllRequests:resolver reject:rejecter]; +} + +RCT_EXPORT_METHOD(clearDNSCache:(RCTPromiseResolveBlock)resolver + rejecter:(RCTPromiseRejectBlock)rejecter) { + [_implementation clearDNSCache:resolver reject:rejecter]; +} +#endif + +@end diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift new file mode 100644 index 00000000..24df23df --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -0,0 +1,185 @@ +import Foundation +import React + +private enum SniConnectError: Error { + case invalidConfig(String) +} + +@objc(SniConnectImpl) +final class SniConnectImpl: NSObject { + private let client: SniConnectClient + private weak var eventSender: AnyObject? + + @objc + init(eventSender: AnyObject) { + self.eventSender = eventSender + self.client = SniConnectClient() + super.init() + + // Set up logging closure for the client + self.client.onLog = { [weak self] level, message in + self?.sendLogEvent(level: level, message: message) + } + } + + private func sendLogEvent(level: String, message: String) { + // Send log event to the Objective-C bridge + guard let eventSender = eventSender else { + return + } + + let logData: [String: Any] = [ + "level": level, + "message": message, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] + + // Call the sendLogEvent method on the bridge + let selector = NSSelectorFromString("sendLogEvent:") + if eventSender.responds(to: selector) { + eventSender.perform(selector, with: logData) + } + } + + @objc + public func request( + _ config: NSDictionary, + resolve resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + do { + let parsed = try Self.parseDictionary(config) + handleRequest(config: parsed, resolve: resolve, reject: reject) + } catch { + NSLog("[SniConnect] ❌ Config parsing failed: \(error)") + sendLogEvent(level: "error", message: "Config parsing failed: \(error)") + reject("SNI_REQUEST_FAILED", error.localizedDescription, error) + } + } + + private func handleRequest( + config: SniConnectClient.RequestConfig, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + // Create task and register it synchronously before it starts executing + let task = Task { () -> SniConnectClient.Response in + return try await client.performRequest(config: config) + } + + // Register task synchronously if requestId is provided + if let requestId = config.requestId { + client.registerTask(task, for: requestId) + } + + // Handle the task result asynchronously + Task { + do { + let result = try await task.value + let responseBody = Self.serializeResponseData(result.data) + + resolve([ + "data": responseBody, + "status": result.status, + "statusText": result.statusText, + "headers": result.headers, + "multiValueHeaders": result.multiValueHeaders, + ]) + } catch let error as SniConnectClient.SniConnectError { + NSLog("[SniConnect] ❌ [\(error.code)] \(error.message)") + sendLogEvent(level: "error", message: "[\(error.code)] \(error.message)") + reject(error.code, error.message, error) + } catch is CancellationError { + NSLog("[SniConnect] ❌ Request cancelled") + sendLogEvent(level: "info", message: "Request cancelled") + reject("SNI_CANCELLED", "Request cancelled", nil) + } catch { + NSLog("[SniConnect] ❌ Request failed: \(error.localizedDescription)") + sendLogEvent(level: "error", message: "Request failed: \(error.localizedDescription)") + reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error) + } + } + } + + @objc + public func cancelRequest( + _ requestId: String, + resolve resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + client.cancelRequest(requestId: requestId) + resolve(["success": true]) + } + + @objc + public func cancelAllRequests( + _ resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + client.cancelAllRequests() + resolve(["success": true]) + } + + @objc + public func clearDNSCache( + _ resolve: @escaping RCTPromiseResolveBlock, + reject reject: @escaping RCTPromiseRejectBlock + ) { + client.clearDNSCache() + resolve(["success": true]) + } + + private static func parseDictionary(_ dictionary: NSDictionary) throws -> SniConnectClient.RequestConfig { + guard let ip = dictionary["ip"] as? String, !ip.isEmpty else { + throw SniConnectError.invalidConfig("Missing ip") + } + guard let hostname = dictionary["hostname"] as? String, !hostname.isEmpty else { + throw SniConnectError.invalidConfig("Missing hostname") + } + + let requestId = dictionary["requestId"] as? String + let method = (dictionary["method"] as? String)?.uppercased() ?? "GET" + let path = dictionary["path"] as? String ?? "/" + let headers = dictionary["headers"] as? [String: String] ?? [:] + let body = dictionary["body"] as? String + let timeout = dictionary["timeout"] as? NSNumber ?? 30_000 + + // Parse advanced timeout configurations + let connectTimeout = (dictionary["connectTimeout"] as? NSNumber)?.doubleValue + let totalTimeout = (dictionary["totalTimeout"] as? NSNumber)?.doubleValue + + return SniConnectClient.RequestConfig( + requestId: requestId, + ip: ip, + hostname: hostname, + method: method, + path: path, + headers: headers, + body: body, + timeout: timeout.doubleValue, + connectTimeout: connectTimeout, + totalTimeout: totalTimeout + ) + } + + private static func serializeResponseData(_ data: Any) -> String { + if let dict = data as? [String: Any], + let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + + if let array = data as? [[String: Any]], + let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + + if let stringValue = data as? String { + return stringValue + } + + return String(describing: data) + } +} + diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift new file mode 100644 index 00000000..5bbe57ca --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -0,0 +1,497 @@ +import Foundation +import EMASCurl + +/// Core HTTPS client that enforces IP direct connection with SNI. +final class SniConnectClient { + + // Logging callback + var onLog: ((String, String) -> Void)? + + // Active requests tracking for cancellation support + private var activeTasks: [String: Task] = [:] + private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent) + + init() { + // Register for memory warnings to clean cache + NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.onLog?("warning", "Memory warning received, cleaning DNS cache") + DNSResolver.cleanExpiredEntries() + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + struct RequestConfig { + let requestId: String? // Optional request ID for cancellation + let ip: String + let hostname: String + let method: String + let path: String + let headers: [String: String] + let body: String? + let timeout: TimeInterval + + // Advanced timeout configurations + let connectTimeout: TimeInterval? // Connection establishment timeout + let totalTimeout: TimeInterval? // Total request timeout (overrides `timeout`) + + var effectiveConnectTimeout: TimeInterval { + connectTimeout ?? min(timeout / 3, 10.0) + } + + var effectiveTotalTimeout: TimeInterval { + totalTimeout ?? timeout + } + } + + struct Response { + let data: Any + let status: Int + let statusText: String + let headers: [String: String] // Single-value headers (backward compatible) + let multiValueHeaders: [String: [String]] // Multi-value headers (e.g., Set-Cookie) + } + + enum SniConnectError: Error { + case invalidURL + case invalidConfig(String) + case dnsResolutionFailed(String) + case tlsHandshakeFailed(String) + case certificateValidationFailed(String) + case connectionTimeout + case connectionRefused + case networkUnreachable + case requestTimeout + case httpError(code: Int, message: String) + case cancelled + case unknown(Error) + + var code: String { + switch self { + case .invalidURL: return "SNI_INVALID_URL" + case .invalidConfig: return "SNI_INVALID_CONFIG" + case .dnsResolutionFailed: return "SNI_DNS_FAILED" + case .tlsHandshakeFailed: return "SNI_TLS_FAILED" + case .certificateValidationFailed: return "SNI_CERT_FAILED" + case .connectionTimeout: return "SNI_TIMEOUT" + case .connectionRefused: return "SNI_CONNECTION_REFUSED" + case .networkUnreachable: return "SNI_NETWORK_UNREACHABLE" + case .requestTimeout: return "SNI_REQUEST_TIMEOUT" + case .httpError: return "SNI_HTTP_ERROR" + case .cancelled: return "SNI_CANCELLED" + case .unknown: return "SNI_UNKNOWN_ERROR" + } + } + + var message: String { + switch self { + case .invalidURL: + return "Invalid URL format" + case .invalidConfig(let details): + return "Invalid configuration: \(details)" + case .dnsResolutionFailed(let domain): + return "DNS resolution failed for domain: \(domain)" + case .tlsHandshakeFailed(let details): + return "TLS handshake failed: \(details)" + case .certificateValidationFailed(let details): + return "Certificate validation failed: \(details)" + case .connectionTimeout: + return "Connection timeout" + case .connectionRefused: + return "Connection refused by server" + case .networkUnreachable: + return "Network unreachable" + case .requestTimeout: + return "Request timeout" + case .httpError(let code, let message): + return "HTTP error \(code): \(message)" + case .cancelled: + return "Request cancelled" + case .unknown(let error): + return "Unknown error: \(error.localizedDescription)" + } + } + + /// Convert NSError to SniConnectError with detailed classification + static func from(_ error: Error) -> SniConnectError { + let nsError = error as NSError + + // Check for URL-related errors + if nsError.domain == NSURLErrorDomain { + switch nsError.code { + case NSURLErrorTimedOut: + return .requestTimeout + case NSURLErrorCannotConnectToHost: + return .connectionRefused + case NSURLErrorNotConnectedToInternet, NSURLErrorNetworkConnectionLost: + return .networkUnreachable + case NSURLErrorSecureConnectionFailed: + return .tlsHandshakeFailed(nsError.localizedDescription) + case NSURLErrorServerCertificateUntrusted, NSURLErrorServerCertificateHasBadDate, + NSURLErrorServerCertificateHasUnknownRoot, NSURLErrorServerCertificateNotYetValid: + return .certificateValidationFailed(nsError.localizedDescription) + case NSURLErrorCannotFindHost, NSURLErrorDNSLookupFailed: + return .dnsResolutionFailed(nsError.localizedDescription) + case NSURLErrorCancelled: + return .cancelled + default: + return .unknown(error) + } + } + + return .unknown(error) + } + } + + private static let urlSession: URLSession = { + let configuration = URLSessionConfiguration.default + configuration.requestCachePolicy = .reloadIgnoringLocalCacheData + configuration.urlCache = nil + configuration.httpCookieStorage = nil + configuration.httpShouldSetCookies = false + configuration.shouldUseExtendedBackgroundIdleMode = false + + let curlConfig = EMASCurlConfiguration.default() + curlConfig.httpVersion = .HTTP2 + curlConfig.connectTimeoutInterval = 2.5 + curlConfig.enableBuiltInGzip = true + curlConfig.enableBuiltInRedirection = true + curlConfig.cacheEnabled = false + + // Enable full certificate validation for security + // The certificate will be validated against the SNI hostname, not the IP + curlConfig.certificateValidationEnabled = true + curlConfig.domainNameVerificationEnabled = true + curlConfig.dnsResolver = DNSResolver.self + + EMASCurlProtocol.install(into: configuration, with: curlConfig) + return URLSession(configuration: configuration) + }() + + @objc private final class DNSResolver: NSObject, EMASCurlProtocolDNSResolver { + private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent) + private static let cache = DNSCache() + + /// LRU cache for DNS mappings with size limit and TTL support + /// Uses hostname:IP as cache key to ensure different IPs for the same hostname are isolated + private class DNSCache { + private struct CacheEntry { + let hostname: String + let ip: String + let timestamp: TimeInterval + } + + private var storage: [String: CacheEntry] = [:] + private var accessOrder: [String] = [] + + // Mapping from hostname to IP for DNS resolution + // This stores the most recently set IP for each hostname + private var hostnameToIP: [String: String] = [:] + + // Maximum cache entries (100 as specified in requirements) + private let maxSize = 100 + // TTL in seconds (5 minutes default) + private let ttl: TimeInterval = 300 + + /// Generate cache key using hostname:IP format + /// This ensures different IPs for the same hostname are isolated (accurate speed testing) + private func makeCacheKey(hostname: String, ip: String) -> String { + return "\(hostname.lowercased()):\(ip)" + } + + func get(_ domain: String) -> String? { + let normalizedDomain = domain.lowercased() + + // Return the most recently set IP for this hostname + return hostnameToIP[normalizedDomain] + } + + func set(_ ip: String, for domain: String) { + let normalizedDomain = domain.lowercased() + let key = makeCacheKey(hostname: normalizedDomain, ip: ip) + + // Update hostname to IP mapping + hostnameToIP[normalizedDomain] = ip + + // Evict oldest entry if cache is full + if storage.count >= maxSize && storage[key] == nil { + if let oldest = accessOrder.first { + storage.removeValue(forKey: oldest) + accessOrder.removeFirst() + } + } + + // Add or update entry + let entry = CacheEntry(hostname: normalizedDomain, ip: ip, timestamp: Date().timeIntervalSince1970) + storage[key] = entry + + // Update access order + if let index = accessOrder.firstIndex(of: key) { + accessOrder.remove(at: index) + } + accessOrder.append(key) + } + + func remove(_ key: String) { + storage.removeValue(forKey: key) + if let index = accessOrder.firstIndex(of: key) { + accessOrder.remove(at: index) + } + } + + func clear() { + storage.removeAll() + accessOrder.removeAll() + hostnameToIP.removeAll() + } + + func cleanExpired() { + let now = Date().timeIntervalSince1970 + let expiredKeys = storage.filter { now - $0.value.timestamp > ttl }.map { $0.key } + for key in expiredKeys { + remove(key) + // Also remove from hostnameToIP if this was the active mapping + if let entry = storage[key], hostnameToIP[entry.hostname] == entry.ip { + hostnameToIP.removeValue(forKey: entry.hostname) + } + } + } + } + + @objc static func resolveDomain(_ domain: String) -> String? { + var result: String? + queue.sync { + result = cache.get(domain) + } + return result + } + + static func setIP(_ ip: String, for host: String) { + queue.sync(flags: .barrier) { + cache.set(ip, for: host) + } + } + + /// Clear all DNS cache entries + static func clearCache() { + queue.sync(flags: .barrier) { + cache.clear() + } + } + + /// Clean expired DNS cache entries + static func cleanExpiredEntries() { + queue.sync(flags: .barrier) { + cache.cleanExpired() + } + } + } + + /// Clear all DNS cache entries + func clearDNSCache() { + DNSResolver.clearCache() + onLog?("info", "DNS cache cleared") + } + + /// Cancel a request by ID + func cancelRequest(requestId: String) { + tasksQueue.async(flags: .barrier) { [weak self] in + guard let task = self?.activeTasks[requestId] else { + self?.onLog?("warning", "No active request found with ID: \(requestId)") + return + } + task.cancel() + self?.activeTasks.removeValue(forKey: requestId) + self?.onLog?("info", "Request cancelled: \(requestId)") + } + } + + /// Cancel all active requests + func cancelAllRequests() { + tasksQueue.async(flags: .barrier) { [weak self] in + guard let self = self else { return } + let count = self.activeTasks.count + for (_, task) in self.activeTasks { + task.cancel() + } + self.activeTasks.removeAll() + self.onLog?("info", "Cancelled \(count) active requests") + } + } + + /// Register an active task (synchronous for immediate registration) + func registerTask(_ task: Task, for requestId: String) { + tasksQueue.sync(flags: .barrier) { [weak self] in + self?.activeTasks[requestId] = task + self?.onLog?("info", "Registered request: \(requestId), total active: \(self?.activeTasks.count ?? 0)") + } + } + + /// Unregister a completed or failed task + private func unregisterTask(requestId: String?) { + guard let requestId = requestId else { return } + tasksQueue.async(flags: .barrier) { [weak self] in + self?.activeTasks.removeValue(forKey: requestId) + self?.onLog?("info", "Unregistered request: \(requestId)") + } + } + + func performRequest(config: RequestConfig) async throws -> Response { + // Check if task is cancelled + try Task.checkCancellation() + + defer { + unregisterTask(requestId: config.requestId) + } + + DNSResolver.setIP(config.ip, for: config.hostname) + + let url = try Self.buildURL(hostname: config.hostname, path: config.path) + + let mutableRequest = NSMutableURLRequest(url: url) + mutableRequest.httpMethod = config.method.uppercased() + + // Convert milliseconds to seconds for timeout values + let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0 + let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0 + + // Set total request timeout + mutableRequest.timeoutInterval = totalTimeoutSeconds + mutableRequest.cachePolicy = .reloadIgnoringLocalCacheData + + // Explicitly set Host header for SNI + mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host") + + for (key, value) in config.headers { + if key.caseInsensitiveCompare("host") == .orderedSame { + // Host header is already set above, skip duplicate + continue + } + mutableRequest.setValue(value, forHTTPHeaderField: key) + } + + if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) { + mutableRequest.httpBody = bodyData + } + + // Configure connection timeout for EMASCurl + EMASCurlProtocol.setConnectTimeoutInterval(connectTimeoutSeconds) + let request = mutableRequest as URLRequest + + do { + let (data, response) = try await Self.urlSession.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + let errorMsg = "Invalid HTTP response type" + onLog?("error", errorMsg) + throw SniConnectError.invalidConfig(errorMsg) + } + + let status = httpResponse.statusCode + let parsedData = Self.parseResponseData(data) + let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse) + let statusText = HTTPURLResponse.localizedString(forStatusCode: status) + + // Check for HTTP errors (4xx, 5xx) + if status >= 400 { + let error = SniConnectError.httpError(code: status, message: statusText) + onLog?("error", "HTTP error: \(error.message)") + // Still return the response for client to handle + } + + return Response( + data: parsedData, + status: status, + statusText: statusText, + headers: headers, + multiValueHeaders: multiValueHeaders + ) + } catch let error as SniConnectError { + onLog?("error", "[\(error.code)] \(error.message)") + throw error + } catch { + // Convert generic errors to specific SniConnectError types + let sniError = SniConnectError.from(error) + onLog?("error", "[\(sniError.code)] \(sniError.message)") + throw sniError + } + } + + private static func buildURL(hostname: String, path: String) throws -> URL { + let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) + + if let url = URL(string: trimmedPath), url.scheme != nil { + return url + } + + let normalizedPath: String + if trimmedPath.isEmpty { + normalizedPath = "/" + } else if trimmedPath.hasPrefix("/") { + normalizedPath = trimmedPath + } else { + normalizedPath = "/" + trimmedPath + } + + guard let url = URL(string: "https://\(hostname)\(normalizedPath)") else { + throw SniConnectError.invalidURL + } + return url + } + + private static func parseResponseData(_ data: Data) -> Any { + guard !data.isEmpty else { + return [:] + } + + if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) { + return jsonObject + } + + if let text = String(data: data, encoding: .utf8) { + return text + } + + return data.base64EncodedString() + } + + /// Extract headers from HTTP response + /// Returns both single-value headers (for backward compatibility) and multi-value headers + private static func extractHeaders(from response: HTTPURLResponse) -> ([String: String], [String: [String]]) { + var singleValueHeaders: [String: String] = [:] + var multiValueHeaders: [String: [String]] = [:] + + // Group headers by normalized key (lowercase) + var headerGroups: [String: [String]] = [:] + + for (key, value) in response.allHeaderFields { + let headerKey = String(describing: key).lowercased() + let headerValue = String(describing: value) + + if headerGroups[headerKey] == nil { + headerGroups[headerKey] = [] + } + headerGroups[headerKey]?.append(headerValue) + } + + // Process grouped headers + for (key, values) in headerGroups { + // For backward compatibility, single-value headers use the last value + singleValueHeaders[key] = values.last + + // Multi-value headers contain all values + if values.count > 1 { + multiValueHeaders[key] = values + } else { + multiValueHeaders[key] = values + } + } + + return (singleValueHeaders, multiValueHeaders) + } +} diff --git a/native-modules/react-native-sni-connect/package.json b/native-modules/react-native-sni-connect/package.json new file mode 100644 index 00000000..f934dfa3 --- /dev/null +++ b/native-modules/react-native-sni-connect/package.json @@ -0,0 +1,157 @@ +{ + "name": "@onekeyfe/react-native-sni-connect", + "version": "3.0.66", + "description": "A React Native library for SNI-based HTTP requests with DNS caching and request management", + "main": "./lib/module/index.js", + "types": "./lib/typescript/src/index.d.ts", + "exports": { + ".": { + "source": "./src/index.tsx", + "types": "./lib/typescript/src/index.d.ts", + "default": "./lib/module/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "src", + "lib", + "android", + "ios", + "*.podspec", + "!ios/build", + "!android/build", + "!android/gradle", + "!android/gradlew", + "!android/gradlew.bat", + "!android/local.properties", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*", + "!**/*.map" + ], + "scripts": { + "clean": "del-cli android/build example/android/build example/android/app/build example/ios/build lib", + "prepare": "bob build", + "typecheck": "tsc", + "lint": "eslint \"**/*.{js,ts,tsx}\"", + "test": "jest", + "release": "yarn prepare && npm whoami && npm publish --access public" + }, + "keywords": [ + "react-native", + "ios", + "android" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git" + }, + "author": "@onekeyhq (https://github.com/OneKeyHQ/app-modules)", + "license": "MIT", + "bugs": { + "url": "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect/issues" + }, + "homepage": "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect#readme", + "publishConfig": { + "registry": "https://registry.npmjs.org/" + }, + "devDependencies": { + "@commitlint/config-conventional": "^19.8.1", + "@eslint/compat": "^1.3.2", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "^9.35.0", + "@react-native/babel-preset": "0.83.0", + "@react-native/eslint-config": "0.83.0", + "@release-it/conventional-changelog": "^10.0.1", + "@types/jest": "^29.5.14", + "@types/react": "^19.2.0", + "commitlint": "^19.8.1", + "del-cli": "^6.0.0", + "eslint": "^9.35.0", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-prettier": "^5.5.4", + "jest": "^29.7.0", + "lefthook": "^2.0.3", + "prettier": "^2.8.8", + "react": "19.2.0", + "react-native": "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch", + "react-native-builder-bob": "^0.40.17", + "release-it": "^19.0.4", + "turbo": "^2.5.6", + "typescript": "^5.9.2" + }, + "peerDependencies": { + "react": "*", + "react-native": "*" + }, + "react-native-builder-bob": { + "source": "src", + "output": "lib", + "targets": [ + [ + "module", + { + "esm": true + } + ], + [ + "typescript", + { + "project": "tsconfig.build.json" + } + ] + ] + }, + "codegenConfig": { + "name": "SniConnectSpec", + "type": "modules", + "jsSrcsDir": "src", + "android": { + "javaPackageName": "com.sniconnect" + } + }, + "prettier": { + "quoteProps": "consistent", + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false + }, + "jest": { + "preset": "react-native", + "modulePathIgnorePatterns": [ + "/example/node_modules", + "/lib/" + ] + }, + "commitlint": { + "extends": [ + "@commitlint/config-conventional" + ] + }, + "release-it": { + "git": { + "commitMessage": "chore: release ${version}", + "tagName": "v${version}" + }, + "npm": { + "publish": true + }, + "github": { + "release": true + }, + "plugins": { + "@release-it/conventional-changelog": { + "preset": { + "name": "angular" + } + } + } + }, + "create-react-native-library": { + "languages": "kotlin-objc", + "type": "turbo-module", + "version": "0.54.8" + } +} diff --git a/native-modules/react-native-sni-connect/scripts/block-ips.sh b/native-modules/react-native-sni-connect/scripts/block-ips.sh new file mode 100755 index 00000000..e11b2240 --- /dev/null +++ b/native-modules/react-native-sni-connect/scripts/block-ips.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +# 配置要屏蔽的 IP 列表 +BLOCKED_IPS=( + # "216.19.4.106" + "104.18.31.39" + # 在这里添加更多 IP +) + +# pf anchor 文件路径 +PF_ANCHOR_FILE="/etc/pf.anchors/block_ips" +PF_CONF="/etc/pf.conf" + +# 检查是否有 sudo 权限 +if [ "$EUID" -ne 0 ]; then + echo "❌ 需要 sudo 权限运行此脚本" + echo "请使用: sudo $0" + exit 1 +fi + +echo "📝 正在生成 pf 规则..." + +# 生成规则文件 +> "$PF_ANCHOR_FILE" # 清空文件 + +for ip in "${BLOCKED_IPS[@]}"; do + echo "block drop out quick from any to $ip" >> "$PF_ANCHOR_FILE" + echo " ✓ 添加屏蔽规则: $ip" +done + +echo "" +echo "📋 生成的规则文件内容:" +cat "$PF_ANCHOR_FILE" + +echo "" +echo "🔄 重新加载 pf 配置..." +pfctl -f "$PF_CONF" 2>&1 | grep -v "Use of -f option" + +echo "" +echo "✅ 当前生效的规则:" +pfctl -a block_ips -s rules 2>&1 | grep -v "ALTQ" + +echo "" +echo "🎉 完成!已屏蔽 ${#BLOCKED_IPS[@]} 个 IP" diff --git a/native-modules/react-native-sni-connect/scripts/sni-request.js b/native-modules/react-native-sni-connect/scripts/sni-request.js new file mode 100644 index 00000000..21f512c5 --- /dev/null +++ b/native-modules/react-native-sni-connect/scripts/sni-request.js @@ -0,0 +1,74 @@ +/** + * SNI Direct IP Connection Test Script + * + * This script demonstrates how to make HTTPS requests using: + * - Direct IP connection (bypassing DNS) + * - SNI (Server Name Indication) with domain name + * - Custom headers for API authentication + */ + +const https = require('https'); + +const options = { + host: '104.18.31.39', // Direct IP connection + // host: '216.19.4.106', + port: 443, + path: '/wallet/v1/account/validate-address?networkId=btc--0&accountAddress=bc1qezh467l5gwkk72v2dx6yj488hlpad8d34u6z2j', + method: 'GET', + servername: 'wallet.onekeytest.com', // CRITICAL: SNI must use domain name for TLS handshake + headers: { + 'Host': 'wallet.onekeytest.com', + 'X-Onekey-Request-ID': 'cc740bab-7cbb-412f-9d9a-1d7b515f601d', + 'X-Onekey-Request-Currency': 'usd', + 'X-Onekey-Request-Locale': 'zh-cn', + 'X-Onekey-Request-Theme': 'light', + 'X-Onekey-Request-Platform': 'android-apk', + 'X-Onekey-Request-Version': '5.16.0', + 'X-Onekey-Request-Build-Number': '2000000000', + 'X-Onekey-Request-Token': 'eyJhbGciOi...', // Truncated token for security + 'X-Onekey-Request-Currency-Value': '1.0', + 'X-Onekey-Instance-Id': '67848a28-b89c-4e0b-8c0f-b87824480d6a', + 'x-onekey-wallet-type': 'hd', + 'x-onekey-hide-asset-details': 'false', + }, +}; + +console.log('🚀 Starting SNI test...'); +console.log(`📡 Connecting to: ${options.host}:${options.port}`); +console.log(`🔐 SNI servername: ${options.servername}`); +console.log(''); + +const req = https.request(options, (res) => { + console.log('✅ Connection successful!'); + console.log(`📊 Status Code: ${res.statusCode}`); + console.log('📋 Response Headers:', JSON.stringify(res.headers, null, 2)); + console.log(''); + + res.setEncoding('utf8'); + let body = ''; + + res.on('data', (chunk) => { + body += chunk; + }); + + res.on('end', () => { + console.log('📦 Response Body:'); + try { + const parsed = JSON.parse(body); + console.log(JSON.stringify(parsed, null, 2)); + } catch (e) { + console.log(body); + } + }); +}); + +req.on('error', (e) => { + console.error('❌ Request failed:', e.message); + console.error('💡 Possible causes:'); + console.error(' - Network connectivity issues'); + console.error(' - Invalid IP address or port'); + console.error(' - SNI configuration mismatch'); + console.error(' - Certificate validation failure'); +}); + +req.end(); diff --git a/native-modules/react-native-sni-connect/scripts/verify-sni.sh b/native-modules/react-native-sni-connect/scripts/verify-sni.sh new file mode 100755 index 00000000..384f388d --- /dev/null +++ b/native-modules/react-native-sni-connect/scripts/verify-sni.sh @@ -0,0 +1,84 @@ +#!/bin/bash + +# SNI Direct IP Connection Verification Tool +# This script captures network traffic to verify that: +# 1. Direct IP connection is established +# 2. SNI (Server Name Indication) is correctly sent during TLS handshake + +# Configuration +TARGET_IP="104.18.31.39" +TARGET_PORT="443" +SNI_HOSTNAME="wallet.onekeytest.com" + +echo "🔍 SNI Direct IP Connection Verification Tool" +echo "==============================================" +echo "" +echo "Target IP: $TARGET_IP:$TARGET_PORT" +echo "SNI Lookup: $SNI_HOSTNAME" +echo "" +echo "⚠️ Prerequisites:" +echo " 1. Your application is running" +echo " 2. Ready to trigger the 'Direct IP Connection' feature" +echo " 3. sudo permission is required for packet capture" +echo "" + +# Check for sudo permission early +if ! sudo -v &>/dev/null; then + echo "❌ Error: sudo permission is required to run tcpdump" + echo "💡 Please run this script with appropriate permissions" + exit 1 +fi + +echo "Press Enter to start monitoring..." +read + +echo "" +echo "🎯 Packet capture started... (Press Ctrl+C to stop)" +echo "==============================================" +echo "" + +# Packet counter +count=0 + +# Capture and analyze packets +# Using -X for hex+ASCII output to better capture binary TLS data +# Using -s 0 to capture full packets (SNI is in early handshake) +sudo tcpdump -i any -n -X -s 0 "host $TARGET_IP and port $TARGET_PORT" 2>/dev/null | while read -r line; do + # Detect TCP connection initiation (SYN) + if [[ $line == *"Flags [S]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then + echo "✅ [TCP] Connection initiated to $TARGET_IP:$TARGET_PORT" + echo "" + fi + + # Detect TCP connection response (SYN-ACK) + if [[ $line == *"Flags [S.]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then + echo "✅ [TCP] Server acknowledged connection" + echo "" + fi + + # Detect SNI field in TLS handshake (both ASCII and hex patterns) + if [[ $line == *"$SNI_HOSTNAME"* ]]; then + count=$((count + 1)) + echo "" + echo "🎉 [TLS] SNI detected (#$count): $SNI_HOSTNAME" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo " ✓ Direct IP Connection + SNI VERIFIED!" + echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + echo "" + fi + + # Display packet summary with timestamps (reduced verbosity) + if [[ $line =~ ^[0-9]{2}:[0-9]{2}:[0-9]{2} ]]; then + if [[ $line == *"> $TARGET_IP.$TARGET_PORT"* ]]; then + # Only show initial request packets to reduce noise + if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S]"* ]]; then + echo "→ [SENT] $line" + fi + elif [[ $line == *"$TARGET_IP.$TARGET_PORT >"* ]]; then + # Only show response packets with data + if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S.]"* ]]; then + echo "← [RECV] $line" + fi + fi + fi +done diff --git a/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts b/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts new file mode 100644 index 00000000..6bc99af4 --- /dev/null +++ b/native-modules/react-native-sni-connect/src/@types/react-native-codegen.d.ts @@ -0,0 +1,8 @@ +declare module 'react-native/Libraries/Types/CodegenTypes' { + export type Int32 = number; + export type Double = number; + export type Float = number; + export type UnsafeObject = Record; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export type WithDefault = Type; +} diff --git a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts new file mode 100644 index 00000000..f009c50d --- /dev/null +++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts @@ -0,0 +1,70 @@ +import { + NativeModules, + Platform, + TurboModuleRegistry, + type TurboModule, +} from 'react-native'; +import type { Double, Int32 } from 'react-native/Libraries/Types/CodegenTypes'; + +type HeaderMap = { + [key: string]: string; +}; + +export type SniConnectRequest = { + requestId?: string; + ip: string; + hostname: string; + method: string; + path: string; + headers: HeaderMap; + body?: string | null; + timeout: Double; +}; + +export type SniConnectResponse = { + data: string; + status: Int32; + statusText: string; + headers: HeaderMap; +}; + +export interface Spec extends TurboModule { + request(config: SniConnectRequest): Promise; + cancelRequest(requestId: string): Promise<{ success: boolean }>; + cancelAllRequests(): Promise<{ success: boolean }>; + clearDNSCache(): Promise<{ success: boolean }>; + + // Event emitter methods required for NativeEventEmitter + addListener(eventType: string): void; + removeListeners(count: Int32): void; +} + +const LINKING_ERROR = + `The package '@onekeyfe/react-native-sni-connect' doesn't seem to be linked. Make sure:\n\n` + + Platform.select({ ios: "- You have run 'pod install'\n", default: '' }) + + '- You rebuilt the app after installing the package\n' + + '- You are not using Expo Go; create a custom dev client instead\n'; + +export type SniConnectModule = Spec & { + request(config: SniConnectRequest): Promise; + cancelRequest(requestId: string): Promise<{ success: boolean }>; + cancelAllRequests(): Promise<{ success: boolean }>; + clearDNSCache(): Promise<{ success: boolean }>; +}; + +const turboModuleResult = TurboModuleRegistry.get('SniConnect'); + +const turboModule: Spec | null = turboModuleResult ?? null; + +const bridgeModule: Spec | null = + (NativeModules.SniConnect as Spec | undefined) ?? null; + +const nativeModule = (turboModule ?? bridgeModule) as SniConnectModule | null; + +if (nativeModule == null) { + throw new Error(LINKING_ERROR); +} + +const NativeSniConnect: SniConnectModule = nativeModule; + +export default NativeSniConnect; diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts new file mode 100644 index 00000000..f90cd7b3 --- /dev/null +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -0,0 +1,435 @@ +import { NativeModules } from 'react-native'; +import { + request, + cancelRequest, + cancelAllRequests, + clearDNSCache, + subscribeToLogs, + type LogEntry, + type SniConnectRequest, + type SniConnectResponse, +} from '../index'; + +// Mock React Native modules +jest.mock('react-native', () => ({ + NativeModules: { + SniConnect: { + request: jest.fn(), + cancelRequest: jest.fn(), + cancelAllRequests: jest.fn(), + clearDNSCache: jest.fn(), + addListener: jest.fn(), + removeListeners: jest.fn(), + }, + }, + NativeEventEmitter: jest.fn().mockImplementation(() => ({ + addListener: jest.fn(() => ({ + remove: jest.fn(), + })), + })), + Platform: { + select: jest.fn((obj) => obj.default), + }, + TurboModuleRegistry: { + get: jest.fn(() => null), + }, +})); + +describe('SniConnect Module', () => { + const mockNativeModule = NativeModules.SniConnect; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('request()', () => { + it('should call native request method with correct config', async () => { + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/api/test', + headers: { Authorization: 'Bearer token' }, + timeout: 30000, + }; + + const mockResponse: SniConnectResponse = { + data: '{"success": true}', + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + }; + + mockNativeModule.request.mockResolvedValue(mockResponse); + + const response = await request(config); + + expect(mockNativeModule.request).toHaveBeenCalledWith(config); + expect(response).toEqual(mockResponse); + }); + + it('should handle request with requestId', async () => { + const config: SniConnectRequest = { + requestId: 'test-123', + ip: '1.1.1.1', + hostname: 'example.com', + method: 'POST', + path: '/api/data', + headers: {}, + body: '{"key": "value"}', + timeout: 30000, + }; + + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 201, + statusText: 'Created', + headers: {}, + }); + + await request(config); + + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + requestId: 'test-123', + }) + ); + }); + + it('should handle request errors', async () => { + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/api/test', + headers: {}, + timeout: 30000, + }; + + const error = new Error('Network error'); + mockNativeModule.request.mockRejectedValue(error); + + await expect(request(config)).rejects.toThrow('Network error'); + }); + + it('should handle different HTTP methods', async () => { + const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']; + + for (const method of methods) { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method, + path: '/test', + headers: {}, + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ method }) + ); + } + }); + }); + + describe('cancelRequest()', () => { + it('should call native cancelRequest with requestId', async () => { + mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); + + const result = await cancelRequest('test-request-123'); + + expect(mockNativeModule.cancelRequest).toHaveBeenCalledWith( + 'test-request-123' + ); + expect(result).toEqual({ success: true }); + }); + + it('should handle cancellation failure', async () => { + mockNativeModule.cancelRequest.mockResolvedValue({ success: false }); + + const result = await cancelRequest('non-existent'); + + expect(result.success).toBe(false); + }); + }); + + describe('cancelAllRequests()', () => { + it('should call native cancelAllRequests', async () => { + mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true }); + + const result = await cancelAllRequests(); + + expect(mockNativeModule.cancelAllRequests).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + describe('clearDNSCache()', () => { + it('should call native clearDNSCache', async () => { + mockNativeModule.clearDNSCache.mockResolvedValue({ success: true }); + + const result = await clearDNSCache(); + + expect(mockNativeModule.clearDNSCache).toHaveBeenCalled(); + expect(result).toEqual({ success: true }); + }); + }); + + describe('subscribeToLogs()', () => { + it('should subscribe to log events and return unsubscribe function', () => { + const mockCallback = jest.fn(); + + const unsubscribe = subscribeToLogs(mockCallback); + + // Should return a function + expect(typeof unsubscribe).toBe('function'); + + // Unsubscribe should not throw + expect(() => unsubscribe()).not.toThrow(); + }); + + it('should call callback when log event is received', () => { + const mockCallback = jest.fn(); + + subscribeToLogs(mockCallback); + + // The actual callback invocation would be triggered by native module + // In unit tests, we're just verifying the subscription was set up correctly + expect(mockCallback).not.toHaveBeenCalled(); // Not called immediately + }); + + it('should handle log entry type correctly', () => { + // Type checking test + const logEntry: LogEntry = { + level: 'info', + message: 'Test log message', + timestamp: Date.now(), + }; + + expect(logEntry).toBeDefined(); + expect(logEntry.level).toBe('info'); + expect(logEntry.message).toBe('Test log message'); + expect(typeof logEntry.timestamp).toBe('number'); + }); + }); + + describe('Request Configuration', () => { + it('should handle empty headers', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith(config); + }); + + it('should handle custom headers', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: { + 'Authorization': 'Bearer token', + 'X-Custom-Header': 'custom-value', + 'Content-Type': 'application/json', + }, + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + 'Authorization': 'Bearer token', + 'X-Custom-Header': 'custom-value', + }), + }) + ); + }); + + it('should handle request body', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'POST', + path: '/api/data', + headers: {}, + body: JSON.stringify({ key: 'value', number: 123 }), + timeout: 30000, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.stringContaining('key'), + }) + ); + }); + + it('should handle different timeout values', async () => { + mockNativeModule.request.mockResolvedValue({ + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }); + + const timeouts = [1000, 5000, 30000, 60000]; + + for (const timeout of timeouts) { + const config: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout, + }; + + await request(config); + expect(mockNativeModule.request).toHaveBeenCalledWith( + expect.objectContaining({ timeout }) + ); + } + }); + }); + + describe('Response Handling', () => { + it('should handle JSON response', async () => { + const mockResponse: SniConnectResponse = { + data: JSON.stringify({ result: 'success', count: 42 }), + status: 200, + statusText: 'OK', + headers: { 'content-type': 'application/json' }, + }; + + mockNativeModule.request.mockResolvedValue(mockResponse); + + const response = await request({ + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }); + + expect(response.data).toContain('success'); + expect(response.headers['content-type']).toBe('application/json'); + }); + + it('should handle plain text response', async () => { + const mockResponse: SniConnectResponse = { + data: 'Plain text response', + status: 200, + statusText: 'OK', + headers: { 'content-type': 'text/plain' }, + }; + + mockNativeModule.request.mockResolvedValue(mockResponse); + + const response = await request({ + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }); + + expect(response.data).toBe('Plain text response'); + }); + + it('should handle different status codes', async () => { + const statusCodes = [200, 201, 204, 400, 404, 500]; + + for (const status of statusCodes) { + mockNativeModule.request.mockResolvedValue({ + data: '', + status, + statusText: 'Status', + headers: {}, + }); + + const response = await request({ + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/test', + headers: {}, + timeout: 30000, + }); + + expect(response.status).toBe(status); + } + }); + }); + + describe('Type Exports', () => { + it('should export LogEntry type', () => { + const log: LogEntry = { + level: 'info', + message: 'test', + timestamp: Date.now(), + }; + expect(log).toBeDefined(); + }); + + it('should export SniConnectRequest type', () => { + const req: SniConnectRequest = { + ip: '1.1.1.1', + hostname: 'example.com', + method: 'GET', + path: '/', + headers: {}, + timeout: 30000, + }; + expect(req).toBeDefined(); + }); + + it('should export SniConnectResponse type', () => { + const res: SniConnectResponse = { + data: '', + status: 200, + statusText: 'OK', + headers: {}, + }; + expect(res).toBeDefined(); + }); + }); +}); diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx new file mode 100644 index 00000000..46efc80d --- /dev/null +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -0,0 +1,46 @@ +import { NativeEventEmitter } from 'react-native'; +import NativeSniConnect, { + type SniConnectRequest, + type SniConnectResponse, +} from './NativeSniConnect'; + +// Log entry type definition +export type LogEntry = { + level: string; + message: string; + timestamp: number; +}; + +// Create event emitter instance +const eventEmitter = new NativeEventEmitter(NativeSniConnect as any); + +// Simple log subscription function +export function subscribeToLogs(callback: (log: LogEntry) => void): () => void { + const subscription = eventEmitter.addListener('SniConnectLog', (log: any) => { + // Type assertion since we know the structure + callback(log as LogEntry); + }); + return () => subscription.remove(); +} + +export function request( + config: SniConnectRequest +): Promise { + return NativeSniConnect.request(config); +} + +export function cancelRequest( + requestId: string +): Promise<{ success: boolean }> { + return NativeSniConnect.cancelRequest(requestId); +} + +export function cancelAllRequests(): Promise<{ success: boolean }> { + return NativeSniConnect.cancelAllRequests(); +} + +export function clearDNSCache(): Promise<{ success: boolean }> { + return NativeSniConnect.clearDNSCache(); +} + +export type { SniConnectRequest, SniConnectResponse } from './NativeSniConnect'; diff --git a/native-modules/react-native-sni-connect/tsconfig.build.json b/native-modules/react-native-sni-connect/tsconfig.build.json new file mode 100644 index 00000000..3c0636ad --- /dev/null +++ b/native-modules/react-native-sni-connect/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig", + "exclude": ["example", "lib"] +} diff --git a/native-modules/react-native-sni-connect/tsconfig.json b/native-modules/react-native-sni-connect/tsconfig.json new file mode 100644 index 00000000..d36b8298 --- /dev/null +++ b/native-modules/react-native-sni-connect/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "rootDir": ".", + "paths": { + "@onekeyfe/react-native-sni-connect": ["./src/index"] + }, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "customConditions": ["react-native-strict-api"], + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "jsx": "react-jsx", + "lib": ["ESNext"], + "module": "ESNext", + "moduleResolution": "bundler", + "noEmit": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noImplicitUseStrict": false, + "noStrictGenericChecks": false, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "target": "ESNext", + "verbatimModuleSyntax": true + } +} diff --git a/native-modules/react-native-sni-connect/turbo.json b/native-modules/react-native-sni-connect/turbo.json new file mode 100644 index 00000000..c4d78c49 --- /dev/null +++ b/native-modules/react-native-sni-connect/turbo.json @@ -0,0 +1,42 @@ +{ + "$schema": "https://turbo.build/schema.json", + "globalDependencies": [".nvmrc", ".yarnrc.yml"], + "globalEnv": ["NODE_ENV"], + "tasks": { + "build:android": { + "env": ["ANDROID_HOME", "ORG_GRADLE_PROJECT_newArchEnabled"], + "inputs": [ + "package.json", + "android", + "!android/build", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/android", + "!example/android/.gradle", + "!example/android/build", + "!example/android/app/build" + ], + "outputs": [] + }, + "build:ios": { + "env": [ + "RCT_NEW_ARCH_ENABLED", + "RCT_USE_RN_DEP", + "RCT_USE_PREBUILT_RNCORE" + ], + "inputs": [ + "package.json", + "*.podspec", + "ios", + "src/*.ts", + "src/*.tsx", + "example/package.json", + "example/ios", + "!example/ios/build", + "!example/ios/Pods" + ], + "outputs": [] + } + } +} diff --git a/yarn.lock b/yarn.lock index 266ebb7f..b5c20ceb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3751,6 +3751,39 @@ __metadata: languageName: unknown linkType: soft +"@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect": + version: 0.0.0-use.local + resolution: "@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect" + dependencies: + "@commitlint/config-conventional": "npm:^19.8.1" + "@eslint/compat": "npm:^1.3.2" + "@eslint/eslintrc": "npm:^3.3.1" + "@eslint/js": "npm:^9.35.0" + "@react-native/babel-preset": "npm:0.83.0" + "@react-native/eslint-config": "npm:0.83.0" + "@release-it/conventional-changelog": "npm:^10.0.1" + "@types/jest": "npm:^29.5.14" + "@types/react": "npm:^19.2.0" + commitlint: "npm:^19.8.1" + del-cli: "npm:^6.0.0" + eslint: "npm:^9.35.0" + eslint-config-prettier: "npm:^10.1.8" + eslint-plugin-prettier: "npm:^5.5.4" + jest: "npm:^29.7.0" + lefthook: "npm:^2.0.3" + prettier: "npm:^2.8.8" + react: "npm:19.2.0" + react-native: "patch:react-native@npm%3A0.83.0#~/.yarn/patches/react-native-npm-0.83.0-577d0f2d83.patch" + react-native-builder-bob: "npm:^0.40.17" + release-it: "npm:^19.0.4" + turbo: "npm:^2.5.6" + typescript: "npm:^5.9.2" + peerDependencies: + react: "*" + react-native: "*" + languageName: unknown + linkType: soft + "@onekeyfe/react-native-splash-screen@workspace:*, @onekeyfe/react-native-splash-screen@workspace:native-modules/react-native-splash-screen": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-splash-screen@workspace:native-modules/react-native-splash-screen" From 96b9c5feee6160976c4e87ce6efce01f83a3be2e Mon Sep 17 00:00:00 2001 From: huhuanming Date: Sat, 13 Jun 2026 01:08:09 +0800 Subject: [PATCH 2/2] fix(sni-connect): harden security, fix concurrency/lifecycle, unify logging via reflection Security (boundary validation, both platforms): - Validate ip as a public IP literal (reject loopback/private/link-local/ metadata/CGNAT/multicast/reserved); reject hostname-as-ip (no DNS). - Force https://hostname:443; reject absolute/scheme/authority paths (no scheme downgrade or host/port override). - Method allowlist, strict hostname, reject CR/LF in header names/values. Concurrency / correctness: - iOS: per-request connect timeout via setConnectTimeoutIntervalFor(...) instead of the process-global setter; simplify DNS cache and fix the cleanExpired read-after-remove bug; fix block-based NotificationCenter observer leak (retain + remove token). - iOS new-arch: forward requestId and null-guard the codegen->dict bridge. - Android: bounded LRU OkHttpClient cache with shared dispatcher/pool; AtomicBoolean guard against double-settling the promise; validate timeout. Logging (Q2): route native logs to OneKeyLog via reflection (SniConnectLog / SniConnectLogger), mirroring BTLogger/SBLLogger, so this TurboModule does not hard-link the nitro-based native-logger; remove the bespoke JS log event channel (RCTEventEmitter / subscribeToLogs). Falls back to os/android log. Cleanup: drop dev scripts/ (sudo pfctl/tcpdump + hardcoded token), fix repository/podspec URLs, rewrite README to the real API, narrow Android packagingOptions, trim dead code. Example: add the module to the example app + Podfile (aliyun spec source, EMASCurl modular_headers). Verified: Android compileDebugKotlin and iOS xcodebuild of the SniConnect target both succeed; tsc + jest pass. --- example/react-native/ios/Podfile | 9 + example/react-native/ios/Podfile.lock | 184 ++++--- example/react-native/package.json | 1 + .../react-native-sni-connect/README.md | 63 ++- .../SniConnect.podspec | 2 +- .../android/build.gradle | 21 - .../java/com/sniconnect/SniConnectLogger.kt | 55 +++ .../java/com/sniconnect/SniConnectModule.kt | 227 ++++----- .../com/sniconnect/SniConnectValidation.kt | 144 ++++++ .../ios/SniConnect-Bridging-Header.h | 1 - .../ios/SniConnect.mm | 60 +-- .../ios/SniConnect.swift | 69 +-- .../ios/SniConnectClient.swift | 204 ++++---- .../ios/SniConnectLog.swift | 29 ++ .../ios/SniConnectValidation.swift | 151 ++++++ .../scripts/block-ips.sh | 44 -- .../scripts/sni-request.js | 74 --- .../scripts/verify-sni.sh | 84 ---- .../src/NativeSniConnect.ts | 11 +- .../src/__tests__/index.test.ts | 465 ++---------------- .../react-native-sni-connect/src/index.tsx | 20 - yarn.lock | 3 +- 22 files changed, 828 insertions(+), 1093 deletions(-) create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt create mode 100644 native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectLog.swift create mode 100644 native-modules/react-native-sni-connect/ios/SniConnectValidation.swift delete mode 100755 native-modules/react-native-sni-connect/scripts/block-ips.sh delete mode 100644 native-modules/react-native-sni-connect/scripts/sni-request.js delete mode 100755 native-modules/react-native-sni-connect/scripts/verify-sni.sh diff --git a/example/react-native/ios/Podfile b/example/react-native/ios/Podfile index 6b191e01..3b73561b 100644 --- a/example/react-native/ios/Podfile +++ b/example/react-native/ios/Podfile @@ -1,3 +1,8 @@ +# Spec sources. The default CDN plus Aliyun's repo, which hosts EMASCurl +# (a transitive native dependency of @onekeyfe/react-native-sni-connect). +source 'https://github.com/CocoaPods/Specs.git' +source 'https://github.com/aliyun/aliyun-specs.git' + # Resolve react_native_pods.rb with node to allow for hoisting ENV['RCT_NEW_ARCH_ENABLED'] = '1' @@ -27,6 +32,10 @@ target 'example' do :app_path => "#{Pod::Config.instance.installation_root}/.." ) + # Enable modular headers for EMASCurl so SniConnect's Swift `import EMASCurl` + # works when building as static libraries (matches app-monorepo). + pod 'EMASCurl', :modular_headers => true + # Copy optional offline TradingView chart material into the app bundle as # `tradingview-assets/`, preserving the # directory tree so react-native-chart-webview's WKURLSchemeHandler can serve it. diff --git a/example/react-native/ios/Podfile.lock b/example/react-native/ios/Podfile.lock index 92b4b7eb..e5d3a9cc 100644 --- a/example/react-native/ios/Podfile.lock +++ b/example/react-native/ios/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - AesCrypto (3.0.61): + - AesCrypto (3.0.66): - boost - DoubleConversion - fast_float @@ -27,7 +27,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AsyncStorage (3.0.61): + - AsyncStorage (3.0.66): - boost - DoubleConversion - fast_float @@ -55,7 +55,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - AutoSizeInput (3.0.61): + - AutoSizeInput (3.0.66): - boost - DoubleConversion - fast_float @@ -85,7 +85,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - BackgroundThread (3.0.61): + - BackgroundThread (3.0.66): - boost - DoubleConversion - fast_float @@ -115,7 +115,7 @@ PODS: - SocketRocket - Yoga - boost (1.84.0) - - ChartWebview (3.0.61): + - ChartWebview (3.0.66): - boost - DoubleConversion - fast_float @@ -146,7 +146,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - CloudFs (3.0.61): + - CloudFs (3.0.66): - boost - DoubleConversion - fast_float @@ -174,7 +174,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - CloudKitModule (3.0.61): + - CloudKitModule (3.0.66): - boost - DoubleConversion - fast_float @@ -208,7 +208,7 @@ PODS: - CocoaLumberjack/Core (3.9.0) - CocoaLumberjack/Swift (3.9.0): - CocoaLumberjack/Core - - DnsLookup (3.0.61): + - DnsLookup (3.0.66): - boost - DoubleConversion - fast_float @@ -237,6 +237,9 @@ PODS: - SocketRocket - Yoga - DoubleConversion (1.1.6) + - EMASCurl (1.5.5): + - EMASCurl/HTTP2 (= 1.5.5) + - EMASCurl/HTTP2 (1.5.5) - fast_float (8.0.0) - FBLazyVector (0.83.0) - fmt (12.1.0) @@ -244,7 +247,7 @@ PODS: - hermes-engine (0.14.0): - hermes-engine/Pre-built (= 0.14.0) - hermes-engine/Pre-built (0.14.0) - - KeychainModule (3.0.61): + - KeychainModule (3.0.66): - boost - DoubleConversion - fast_float @@ -278,7 +281,7 @@ PODS: - MMKV (2.2.4): - MMKVCore (~> 2.2.4) - MMKVCore (2.2.4) - - NetworkInfo (3.0.61): + - NetworkInfo (3.0.66): - boost - DoubleConversion - fast_float @@ -366,7 +369,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Pbkdf2 (3.0.61): + - Pbkdf2 (3.0.66): - boost - DoubleConversion - fast_float @@ -394,7 +397,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - PerpDepthBar (3.0.61): + - PerpDepthBar (3.0.66): - boost - DoubleConversion - fast_float @@ -424,7 +427,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Ping (3.0.61): + - Ping (3.0.66): - boost - DoubleConversion - fast_float @@ -494,6 +497,7 @@ PODS: - React-RCTText (= 0.83.0) - React-RCTVibration (= 0.83.0) - React-callinvoker (0.83.0) + - React-Codegen (0.1.0) - React-Core (0.83.0): - boost - DoubleConversion @@ -2336,7 +2340,7 @@ PODS: - React-RCTFBReactNativeSpec - ReactCommon/turbomodule/core - SocketRocket - - react-native-pager-view (3.0.61): + - react-native-pager-view (3.0.66): - boost - DoubleConversion - fast_float @@ -2451,7 +2455,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view (3.0.61): + - react-native-tab-view (3.0.66): - boost - DoubleConversion - fast_float @@ -2469,7 +2473,7 @@ PODS: - React-graphics - React-ImageManager - React-jsi - - react-native-tab-view/common (= 3.0.61) + - react-native-tab-view/common (= 3.0.66) - React-NativeModulesApple - React-RCTFabric - React-renderercss @@ -2480,7 +2484,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - react-native-tab-view/common (3.0.61): + - react-native-tab-view/common (3.0.66): - boost - DoubleConversion - fast_float @@ -3065,7 +3069,7 @@ PODS: - React-perflogger (= 0.83.0) - React-utils (= 0.83.0) - SocketRocket - - ReactNativeAppUpdate (3.0.61): + - ReactNativeAppUpdate (3.0.66): - boost - DoubleConversion - fast_float @@ -3096,7 +3100,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleCrypto (3.0.61): + - ReactNativeBundleCrypto (3.0.66): - boost - DoubleConversion - fast_float @@ -3127,7 +3131,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeBundleUpdate (3.0.61): + - ReactNativeBundleUpdate (3.0.66): - boost - DoubleConversion - fast_float @@ -3162,7 +3166,7 @@ PODS: - SocketRocket - SSZipArchive (>= 2.5.4) - Yoga - - ReactNativeCheckBiometricAuthChanged (3.0.61): + - ReactNativeCheckBiometricAuthChanged (3.0.66): - boost - DoubleConversion - fast_float @@ -3193,7 +3197,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeDeviceUtils (3.0.61): + - ReactNativeDeviceUtils (3.0.66): - boost - DoubleConversion - fast_float @@ -3224,7 +3228,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeGetRandomValues (3.0.61): + - ReactNativeGetRandomValues (3.0.66): - boost - DoubleConversion - fast_float @@ -3255,7 +3259,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeLiteCard (3.0.61): + - ReactNativeLiteCard (3.0.66): - boost - DoubleConversion - fast_float @@ -3284,7 +3288,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeNativeLogger (3.0.61): + - ReactNativeNativeLogger (3.0.66): - boost - CocoaLumberjack/Swift (~> 3.8) - DoubleConversion @@ -3315,7 +3319,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ReactNativePerfMemory (3.0.61): + - ReactNativePerfMemory (3.0.66): - boost - DoubleConversion - fast_float @@ -3346,7 +3350,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativePerfStats (3.0.61): + - ReactNativePerfStats (3.0.66): - boost - DoubleConversion - fast_float @@ -3377,7 +3381,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeRangeDownloader (3.0.61): + - ReactNativeRangeDownloader (3.0.66): - boost - DoubleConversion - fast_float @@ -3408,7 +3412,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeSplashScreen (3.0.61): + - ReactNativeSplashScreen (3.0.66): - boost - DoubleConversion - fast_float @@ -3439,7 +3443,7 @@ PODS: - ReactNativeNativeLogger - SocketRocket - Yoga - - ReactNativeZipArchive (3.0.61): + - ReactNativeZipArchive (3.0.66): - boost - DoubleConversion - fast_float @@ -3528,7 +3532,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - ScrollGuard (3.0.61): + - ScrollGuard (3.0.66): - boost - DoubleConversion - fast_float @@ -3558,7 +3562,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - SegmentSlider (3.0.61): + - SegmentSlider (3.0.66): - boost - DoubleConversion - fast_float @@ -3588,7 +3592,7 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga - - Skeleton (3.0.61): + - Skeleton (3.0.66): - boost - DoubleConversion - fast_float @@ -3618,8 +3622,39 @@ PODS: - ReactCommon/turbomodule/core - SocketRocket - Yoga + - SniConnect (3.0.66): + - boost + - DoubleConversion + - EMASCurl (= 1.5.5) + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Codegen + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - SocketRocket (0.7.1) - - SplitBundleLoader (3.0.61): + - SplitBundleLoader (3.0.66): - boost - DoubleConversion - fast_float @@ -3649,7 +3684,7 @@ PODS: - SocketRocket - Yoga - SSZipArchive (2.6.0) - - TcpSocket (3.0.61): + - TcpSocket (3.0.66): - boost - DoubleConversion - fast_float @@ -3690,6 +3725,7 @@ DEPENDENCIES: - "CloudKitModule (from `../../../node_modules/@onekeyfe/react-native-cloud-kit-module`)" - "DnsLookup (from `../../../node_modules/@onekeyfe/react-native-dns-lookup`)" - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - EMASCurl - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) @@ -3792,12 +3828,17 @@ DEPENDENCIES: - "ScrollGuard (from `../../../node_modules/@onekeyfe/react-native-scroll-guard`)" - "SegmentSlider (from `../../../node_modules/@onekeyfe/react-native-segment-slider`)" - "Skeleton (from `../../../node_modules/@onekeyfe/react-native-skeleton`)" + - "SniConnect (from `../../../node_modules/@onekeyfe/react-native-sni-connect`)" - SocketRocket (~> 0.7.1) - "SplitBundleLoader (from `../../../node_modules/@onekeyfe/react-native-split-bundle-loader`)" - "TcpSocket (from `../../../node_modules/@onekeyfe/react-native-tcp-socket`)" - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: + https://github.com/aliyun/aliyun-specs.git: + - EMASCurl + https://github.com/CocoaPods/Specs.git: + - React-Codegen trunk: - CocoaLumberjack - MMKV @@ -4029,6 +4070,8 @@ EXTERNAL SOURCES: :path: "../../../node_modules/@onekeyfe/react-native-segment-slider" Skeleton: :path: "../../../node_modules/@onekeyfe/react-native-skeleton" + SniConnect: + :path: "../../../node_modules/@onekeyfe/react-native-sni-connect" SplitBundleLoader: :path: "../../../node_modules/@onekeyfe/react-native-split-bundle-loader" TcpSocket: @@ -4037,31 +4080,32 @@ EXTERNAL SOURCES: :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: - AesCrypto: 5d79d012eda0c688e81fb1e5efb66697a8bf63b2 - AsyncStorage: d36fdf626a5daea9ab7ddf147743f91cb8fc47b2 - AutoSizeInput: 46d8edf6010443fdca7dc3a69bb44553780b1155 - BackgroundThread: 28c77551040ecc7a70dabf132b840f7b6b1cb7ef + AesCrypto: c1bf211cf41135c255df481b6474620d90944a61 + AsyncStorage: 2521eaa9e267dbb236912e6203031780054f2fac + AutoSizeInput: 935e5589d4643a686314474588a2f884cd79fa03 + BackgroundThread: f57ed61f48fc67af75249053cf74a391c0a4933d boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 - ChartWebview: df5cfa93f2fff737382f08041ba8034932266674 - CloudFs: 7d28ea2cc410fcae83fa573f4d5c313f3203352d - CloudKitModule: 9f6c25469fa12adbcf55b0f9ec9a26bb489978c6 + ChartWebview: cbd9d05147be896535d76c76a2ace1bc1054963b + CloudFs: 2690dc61a62394b67992678efd6ca1d3a502d61f + CloudKitModule: 4861dcf5360d70f327c11cdd8373e2c368b648f1 CocoaLumberjack: 5644158777912b7de7469fa881f8a3f259c2512a - DnsLookup: 2b92e6ee2a0925934097334e7024214ee39f3fd0 + DnsLookup: 341981fbc4687d23e0c02f9749f00b638f0e4786 DoubleConversion: cb417026b2400c8f53ae97020b2be961b59470cb + EMASCurl: d75387e1ce9dec1a75cd25cb33c7a7e7bf21997f fast_float: b32c788ed9c6a8c584d114d0047beda9664e7cc6 FBLazyVector: a293a88992c4c33f0aee184acab0b64a08ff9458 fmt: 530618a01105dae0fa3a2f27c81ae11fa8f67eac glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 hermes-engine: 70fdc9d0bb0d8532e0411dcb21e53ce5a160960a - KeychainModule: 91e5f89dfcc8981beb4ee8ad1480eed76ff1d96a + KeychainModule: 8f41a100d9d34335eaf2d4792ba2956ce88866e5 MMKV: 1a8e7dbce7f9cad02c52e1b1091d07bd843aefaf MMKVCore: f2dd4c9befea04277a55e84e7812f930537993df - NetworkInfo: aff4df0ccff078e448ad3da1c5620e7285f129fc + NetworkInfo: aa0fa012d7fd9dc5aa4e4cc35b89322207f59ad8 NitroMmkv: 0be91455465952f2b943f753b9ee7df028d89e5c NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3 - Pbkdf2: 723893ae9eaa320168c62cb825a4bcef89703894 - PerpDepthBar: ce30834568bd8b9ea933267437defb431c6c576d - Ping: 8f45505d4f773a5f6348852679e2801e1bf052ac + Pbkdf2: 39cdd7f31a7936f70addc722a75b99fde194f801 + PerpDepthBar: 121bdfc2bf3e388610a71c831a78214f1455078b + Ping: adffa990ffb4060dbe4754405124d6df45b2458d RCT-Folly: b29feb752b08042c62badaef7d453f3bb5e6ae23 RCTDeprecation: 2b70c6e3abe00396cefd8913efbf6a2db01a2b36 RCTRequired: f3540eee8094231581d40c5c6d41b0f170237a81 @@ -4070,6 +4114,7 @@ SPEC CHECKSUMS: RCTTypeSafety: 6359ff3fcbe18c52059f4d4ce301e47f9da5f0d5 React: f6f8fc5c01e77349cdfaf49102bcb928ac31d8ed React-callinvoker: 032b6d1d03654b9fb7de9e2b3b978d3cb1a893ad + React-Codegen: 4b8b4817cea7a54b83851d4c1f91f79aa73de30a React-Core: 418c9278f8a071b44a88a87be9a4943234cc2e77 React-CoreModules: 925b8cb677649f967f6000f9b1ef74dc4ff60c30 React-cxxreact: 21f6f0cb2a7d26fbed4d09e04482e5c75662beaf @@ -4098,9 +4143,9 @@ SPEC CHECKSUMS: React-logger: 9e597cbeda7b8cc8aa8fb93860dade97190f69cc React-Mapbuffer: 20046c0447efaa7aace0b76085aa9bb35b0e8105 React-microtasksnativemodule: 0e837de56519c92d8a2e3097717df9497feb33cb - react-native-pager-view: 5bde74d01c86137851dddc589a91566e5fa2a490 + react-native-pager-view: 7f97a6caeec2a8c8abd37e6bd2d216dae14c7a68 react-native-safe-area-context: c00143b4823773bba23f2f19f85663ae89ceb460 - react-native-tab-view: 84fd866023d2753d09cdb1dd62cbe086fb39ccc7 + react-native-tab-view: 843a641740637e3c076e6343e1261394db417e8b React-NativeModulesApple: 1a378198515f8e825c5931a7613e98da69320cee React-networking: bfd1695ada5a57023006ce05823ac5391c3ce072 React-oscompat: aedc0afbded67280de6bb6bfac8cfde0389e2b33 @@ -4134,29 +4179,30 @@ SPEC CHECKSUMS: ReactAppDependencyProvider: ebcf3a78dc1bcdf054c9e8d309244bade6b31568 ReactCodegen: 554b421c45b7df35ac791da1b734335470b55fcc ReactCommon: 424cc34cf5055d69a3dcf02f3436481afb8b0f6f - ReactNativeAppUpdate: d4931ea70ba02c29bec1a3db68ef1b2d0b39f2c2 - ReactNativeBundleCrypto: 9b44893be06c117be94f9d5eb7f2666c0b4ee731 - ReactNativeBundleUpdate: 0a352e1fe87dfeefea7b7e477834c6830900500d - ReactNativeCheckBiometricAuthChanged: a422d1c4e87f5de024409e8c6b97469bebe271ec - ReactNativeDeviceUtils: f5b15156b5864fc43ba71b3ec09ad9ec3111e9bb - ReactNativeGetRandomValues: 94a7f6bc3fcb91991d6aaa574d4ad0c140ff5285 - ReactNativeLiteCard: 663a9d5772550d83de6aa79282c9c3fc5410fd15 - ReactNativeNativeLogger: 6337f2f51c7f7c62a8575764799a74d22ffec372 - ReactNativePerfMemory: b0d2ac23e8bcd2e07418efe92a3e19379666bb2e - ReactNativePerfStats: bdff04f45bbd161e0e3c907d1bb36e8ddb7410c0 - ReactNativeRangeDownloader: 73efe1ec206257b2f425d58e7e238f45221ae707 - ReactNativeSplashScreen: 6223f61235b36f12f468e369064416854608aa75 - ReactNativeZipArchive: bdf08f7ee00352d2b2894449c8b1b6810f40f2b2 + ReactNativeAppUpdate: 1eac55fd1fa44816bb68d42a7f2e333880ac3ddb + ReactNativeBundleCrypto: c9f74b20cf635cbd534edee13a65fb55f6815359 + ReactNativeBundleUpdate: 573f062683f448a72c38672d69aa9781e7d48b27 + ReactNativeCheckBiometricAuthChanged: 3d280d3594c1327ee5fe4a5d6f6ef59fecf46375 + ReactNativeDeviceUtils: 3da5317e60cc777c3aa47fc746be8c931631be60 + ReactNativeGetRandomValues: 950ff0d769f9260000caffa18681e9aad092b9e2 + ReactNativeLiteCard: 23ea28539156c3270a7fd277ce02f4a26e0dabde + ReactNativeNativeLogger: 6ccaed6def6d2e1b52fef0901f34e79dcb6436f5 + ReactNativePerfMemory: d38d0ba81f149400311636dd9ea98891e1be2235 + ReactNativePerfStats: ad2c7ab56937a34123dd8168bd57e2ee093a97bd + ReactNativeRangeDownloader: 5b55d8eb35eeaa1af87f1e9eb2c55fb69b03c22a + ReactNativeSplashScreen: 0b1cb4fbd97604af746248145f2e820911d75369 + ReactNativeZipArchive: fb1928a0ae11a523e42857cbf55ce8ccb89c6318 RNScreens: 7f643ee0fd1407dc5085c7795460bd93da113b8f - ScrollGuard: 61f6774ecb5ac6ccd5fbeb34830a117ab3e2d4f0 - SegmentSlider: 295a0ade25855058c11d2b185ad13762b1f4a166 - Skeleton: 07da4b794e0e2b58bd75ba820d0c2e6e87c9225e + ScrollGuard: b6fa0a7cc51ade6238cc0ad94e59b59b261ffb06 + SegmentSlider: 4382a67176bed36e68fcbf263683ac12f3f904d8 + Skeleton: 1ca94119dfd1d0fe3efd2fecda43c13a78a76be8 + SniConnect: 7c2ace3603235ac8d4eabfa3cb9dde5ee2a5e7ec SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 - SplitBundleLoader: 8421ede8cbc7f15a7608b59b126fcfe22aca5ccc + SplitBundleLoader: dff61bc974752e162221f99fe25cf3ec0ad35566 SSZipArchive: 8a6ee5677c8e304bebc109e39cf0da91ccef22ea - TcpSocket: bd4063e4dda0fb7f6952376592e3efdeac915710 + TcpSocket: 4bbe12c84aa8c6513106bf4c9f462c060c6b5aa3 Yoga: 6ca93c8c13f56baeec55eb608577619b17a4d64e -PODFILE CHECKSUM: 11e5274bb8ec6380d5fbde829e700fe4f0cf9c8a +PODFILE CHECKSUM: 7dbc28b8923a22b4d9fcd35e5fe10389c4fccc09 COCOAPODS: 1.16.2 diff --git a/example/react-native/package.json b/example/react-native/package.json index cf10c522..248215d6 100644 --- a/example/react-native/package.json +++ b/example/react-native/package.json @@ -39,6 +39,7 @@ "@onekeyfe/react-native-scroll-guard": "workspace:*", "@onekeyfe/react-native-segment-slider": "workspace:*", "@onekeyfe/react-native-skeleton": "workspace:*", + "@onekeyfe/react-native-sni-connect": "workspace:*", "@onekeyfe/react-native-splash-screen": "workspace:*", "@onekeyfe/react-native-split-bundle-loader": "workspace:*", "@onekeyfe/react-native-tab-view": "workspace:*", diff --git a/native-modules/react-native-sni-connect/README.md b/native-modules/react-native-sni-connect/README.md index ae6ef919..099779c5 100644 --- a/native-modules/react-native-sni-connect/README.md +++ b/native-modules/react-native-sni-connect/README.md @@ -1,37 +1,62 @@ -# react-native-sni-connect +# @onekeyfe/react-native-sni-connect -onekey sni http client +OneKey SNI HTTP client for React Native. Performs HTTPS requests to a caller-supplied +IP address while preserving the original TLS SNI / `Host` of a hostname, so certificate +chain and hostname verification are still enforced against the real hostname (not the IP). + +Backed by EMASCurl (libcurl) on iOS and OkHttp on Android. ## Installation +This package is published as part of the OneKey `app-modules` workspace: ```sh -npm install react-native-sni-connect +yarn add @onekeyfe/react-native-sni-connect ``` +iOS: run `pod install`. Android autolinks. ## Usage - -```js -import { multiply } from 'react-native-sni-connect'; - -// ... - -const result = multiply(3, 7); +```ts +import { + request, + cancelRequest, + cancelAllRequests, + clearDNSCache, +} from '@onekeyfe/react-native-sni-connect'; + +const res = await request({ + // requestId is optional; required only if you want to cancel the request. + requestId: 'health-check-1', + ip: '93.184.216.34', // must be a public IP literal (private/loopback/metadata are rejected) + hostname: 'example.com', // used for SNI, Host header and certificate validation + method: 'GET', + path: '/api/v1/ping', // relative path only — absolute URLs are rejected + headers: { 'Content-Type': 'application/json' }, + timeout: 30_000, +}); + +console.log(res.status, res.headers, res.data); + +// Cancellation (requires requestId on the request) +await cancelRequest('health-check-1'); +await cancelAllRequests(); + +// Drop pinned-IP connections / cached clients +await clearDNSCache(); ``` +### Security notes -## Contributing - -- [Development workflow](CONTRIBUTING.md#development-workflow) -- [Sending a pull request](CONTRIBUTING.md#sending-a-pull-request) -- [Code of conduct](CODE_OF_CONDUCT.md) +- The request scheme is always `https` on port `443`; `path` cannot override scheme, host or port. +- `ip` must be an IPv4/IPv6 literal that routes to a public destination; loopback, private, + link-local (incl. cloud metadata), CGNAT, multicast and reserved ranges are rejected. +- Header names/values containing CR/LF/control characters are rejected; the `Host` header is + managed by the module. +- Native logs go through OneKey's native logger (with sensitive-data redaction); there is no + JS log event channel. ## License MIT - ---- - -Made with [create-react-native-library](https://github.com/callstack/react-native-builder-bob) diff --git a/native-modules/react-native-sni-connect/SniConnect.podspec b/native-modules/react-native-sni-connect/SniConnect.podspec index cd05d30f..8ff6dbf0 100644 --- a/native-modules/react-native-sni-connect/SniConnect.podspec +++ b/native-modules/react-native-sni-connect/SniConnect.podspec @@ -11,7 +11,7 @@ Pod::Spec.new do |s| s.authors = package["author"] s.platforms = { :ios => min_ios_version_supported } - s.source = { :git => "https://github.com/OneKeyHQ/react-native-sni-connect.git", :tag => "#{s.version}" } + s.source = { :git => "https://github.com/OneKeyHQ/app-modules/react-native-sni-connect.git", :tag => "#{s.version}" } s.static_framework = true diff --git a/native-modules/react-native-sni-connect/android/build.gradle b/native-modules/react-native-sni-connect/android/build.gradle index 90287b90..efd08421 100644 --- a/native-modules/react-native-sni-connect/android/build.gradle +++ b/native-modules/react-native-sni-connect/android/build.gradle @@ -34,27 +34,6 @@ android { targetSdkVersion getExtOrIntegerDefault("targetSdkVersion") } - packagingOptions { - excludes = [ - "META-INF", - "META-INF/**", - "**/libc++_shared.so", - "**/libfbjni.so", - "**/libjsi.so", - "**/libfolly_json.so", - "**/libfolly_runtime.so", - "**/libglog.so", - "**/libhermes.so", - "**/libhermes-executor-debug.so", - "**/libhermes_executor.so", - "**/libreactnative.so", - "**/libreactnativejni.so", - "**/libturbomodulejsijni.so", - "**/libreact_nativemodule_core.so", - "**/libjscexecutor.so" - ] - } - buildFeatures { buildConfig true } diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt new file mode 100644 index 00000000..d4489394 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectLogger.kt @@ -0,0 +1,55 @@ +package com.sniconnect + +/** + * Lightweight logging wrapper that dynamically dispatches to OneKeyLog. + * Uses reflection to avoid a hard dependency on the (nitro-based) native-logger + * module. Falls back to android.util.Log when OneKeyLog is not available. + * + * Mirrors iOS SniConnectLog and the existing BTLogger / SBLLogger. + */ +internal object SniConnectLogger { + private const val TAG = "SniConnect" + + private val logClass: Class<*>? by lazy { + try { + Class.forName("com.margelo.nitro.nativelogger.OneKeyLog") + } catch (_: ClassNotFoundException) { + null + } + } + + private val methods by lazy { + val cls = logClass ?: return@lazy null + mapOf( + "debug" to cls.getMethod("debug", String::class.java, String::class.java), + "info" to cls.getMethod("info", String::class.java, String::class.java), + "warn" to cls.getMethod("warn", String::class.java, String::class.java), + "error" to cls.getMethod("error", String::class.java, String::class.java), + ) + } + + @JvmStatic + fun debug(message: String) = log("debug", message, android.util.Log.DEBUG) + + @JvmStatic + fun info(message: String) = log("info", message, android.util.Log.INFO) + + @JvmStatic + fun warn(message: String) = log("warn", message, android.util.Log.WARN) + + @JvmStatic + fun error(message: String) = log("error", message, android.util.Log.ERROR) + + private fun log(level: String, message: String, androidLogLevel: Int) { + val method = methods?.get(level) + if (method != null) { + try { + method.invoke(null, TAG, message) + return + } catch (_: Exception) { + // Fall through to android.util.Log + } + } + android.util.Log.println(androidLogLevel, TAG, message) + } +} diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt index 284f64f9..a86da5da 100644 --- a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -1,16 +1,16 @@ package com.sniconnect -import android.util.Log import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.ReadableMap import com.facebook.react.bridge.WritableMap -import com.facebook.react.modules.core.DeviceEventManagerModule import com.facebook.react.module.annotations.ReactModule import okhttp3.Call import okhttp3.Callback +import okhttp3.ConnectionPool +import okhttp3.Dispatcher import okhttp3.Dns import okhttp3.Headers import okhttp3.MediaType.Companion.toMediaTypeOrNull @@ -21,10 +21,10 @@ import okhttp3.Response import okhttp3.ResponseBody import java.io.IOException import java.net.InetAddress -import java.net.UnknownHostException import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean import javax.net.ssl.HttpsURLConnection private const val TAG = "SniConnect" @@ -35,65 +35,49 @@ class SniConnectModule(reactContext: ReactApplicationContext) : companion object { const val NAME = "SniConnect" - private const val EVENT_NAME = "SniConnectLog" + + // Upper bound on cached OkHttpClient instances to prevent unbounded growth + // from JS-controlled host/IP pairs (e.g. speed-testing many endpoints). + private const val MAX_CLIENTS = 32 + + // A single dispatcher + connection pool shared across all cached clients so we + // don't spawn a thread pool / connection pool per (hostname, ip) pair. + private val sharedDispatcher = Dispatcher() + private val sharedConnectionPool = ConnectionPool() } /** - * Cache key for OkHttpClient instances - * Uses hostname:IP combination to ensure: - * - Different IPs for the same hostname are isolated (accurate speed testing) - * - Same hostname+IP combination can reuse connections (performance optimization) - * Note: timeout is NOT part of the key to allow connection reuse across different timeout values + * Cache key for OkHttpClient instances. + * Uses hostname:IP so different IPs for the same hostname stay isolated (accurate + * speed testing) while the same hostname+IP reuses connections. Timeout is NOT part + * of the key — it is applied per-call via `call.timeout()`. */ private data class ClientKey( val hostname: String, val ip: String, ) - private val clientCache = ConcurrentHashMap() - private val activeCalls = ConcurrentHashMap() - private var listenerCount = 0 - - override fun getName(): String = NAME - - // Event emitter methods required for NativeEventEmitter - @ReactMethod - override fun addListener(eventType: String) { - listenerCount += 1 - } - - @ReactMethod - override fun removeListeners(count: Double) { - listenerCount -= count.toInt() - if (listenerCount < 0) { - listenerCount = 0 + // Bounded LRU: access-ordered, evicts the eldest client when capacity is exceeded. + // Idle connections are reclaimed by the shared ConnectionPool's own keep-alive, so + // we do NOT evictAll here (that pool is shared by every client). Synchronized because + // LinkedHashMap is not thread-safe. + private val clientCache = object : LinkedHashMap(16, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry): Boolean { + return size > MAX_CLIENTS } } - private fun sendLogEvent(level: String, message: String) { - if (listenerCount > 0) { - val logData = Arguments.createMap().apply { - putString("level", level) - putString("message", message) - putDouble("timestamp", System.currentTimeMillis().toDouble()) - } + private val activeCalls = ConcurrentHashMap() - reactApplicationContext - .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter::class.java) - ?.emit(EVENT_NAME, logData) - } - } + override fun getName(): String = NAME override fun request(config: ReadableMap, promise: Promise) { try { val requestConfig = config.toRequestConfig() - // Log module initialization - sendLogEvent("info", "SniConnect module initialized successfully") performRequest(requestConfig, promise) } catch (error: Exception) { - Log.e(TAG, "[SniConnect] Request failed", error) - sendLogEvent("error", "Config parsing failed: ${error.message}") - promise.reject("SNI_REQUEST_FAILED", error.message, error) + SniConnectLogger.error("Config parsing failed: ${error.message}") + promise.reject("SNI_INVALID_CONFIG", error.message, error) } } @@ -102,38 +86,31 @@ class SniConnectModule(reactContext: ReactApplicationContext) : val call = activeCalls.remove(requestId) if (call != null) { call.cancel() - sendLogEvent("info", "Cancelled request: $requestId") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", true) - }) + SniConnectLogger.info("Cancelled request: $requestId") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } else { - sendLogEvent("info", "Request not found: $requestId") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", false) - }) + promise.resolve(Arguments.createMap().apply { putBoolean("success", false) }) } } @ReactMethod override fun cancelAllRequests(promise: Promise) { val count = activeCalls.size - activeCalls.forEach { (_, call) -> - call.cancel() - } + activeCalls.forEach { (_, call) -> call.cancel() } activeCalls.clear() - sendLogEvent("info", "Cancelled $count active requests") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", true) - }) + SniConnectLogger.info("Cancelled $count active requests") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } @ReactMethod override fun clearDNSCache(promise: Promise) { - clientCache.clear() - sendLogEvent("info", "DNS cache cleared") - promise.resolve(Arguments.createMap().apply { - putBoolean("success", true) - }) + synchronized(clientCache) { + clientCache.clear() + } + // Drop pinned-IP connections from the shared pool. + sharedConnectionPool.evictAll() + SniConnectLogger.info("DNS cache cleared") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) } private fun performRequest(config: RequestConfig, promise: Promise) { @@ -148,52 +125,55 @@ class SniConnectModule(reactContext: ReactApplicationContext) : // Register the call if requestId is provided config.requestId?.let { requestId -> activeCalls[requestId] = call - sendLogEvent("info", "Registered request: $requestId, total active: ${activeCalls.size}") } + // Guard against double-settling the promise (RN hard-crashes otherwise). + val settled = AtomicBoolean(false) + call.enqueue(object : Callback { override fun onFailure(call: Call, e: IOException) { - // Unregister the call config.requestId?.let { activeCalls.remove(it) } + if (!settled.compareAndSet(false, true)) return if (call.isCanceled()) { - sendLogEvent("info", "Request cancelled") promise.reject("SNI_CANCELLED", "Request cancelled", null) } else { - Log.e(TAG, "[SniConnect] Request failed", e) - sendLogEvent("error", "Request failed: ${e.message}") + SniConnectLogger.error("Request failed: ${e.message}") promise.reject("SNI_REQUEST_FAILED", e.message, e) } } override fun onResponse(call: Call, response: Response) { - // Unregister the call config.requestId?.let { activeCalls.remove(it) } - try { + val result: WritableMap = try { response.use { val bodyString = response.body.safeString() val headerMap = headersToMap(response.headers) - - val result: WritableMap = Arguments.createMap().apply { + Arguments.createMap().apply { putString("data", bodyString) putInt("status", response.code) putString("statusText", response.message) putMap("headers", headerMap.toWritableMap()) } - - promise.resolve(result) } } catch (error: Exception) { - Log.e(TAG, "[SniConnect] Response processing failed", error) - sendLogEvent("error", "Response processing failed: ${error.message}") + if (!settled.compareAndSet(false, true)) return + SniConnectLogger.error("Response processing failed: ${error.message}") promise.reject("SNI_RESPONSE_FAILED", error.message, error) + return + } + + if (response.code >= 400) { + SniConnectLogger.warn("HTTP ${response.code} for ${config.hostname}") + } + if (settled.compareAndSet(false, true)) { + promise.resolve(result) } } }) } catch (error: Exception) { - Log.e(TAG, "[SniConnect] Request setup failed", error) - sendLogEvent("error", "Request setup failed: ${error.message}") + SniConnectLogger.error("Request setup failed: ${error.message}") promise.reject("SNI_REQUEST_FAILED", error.message, error) } } @@ -202,47 +182,54 @@ class SniConnectModule(reactContext: ReactApplicationContext) : val normalizedHost = config.hostname.lowercase(Locale.US) val key = ClientKey(normalizedHost, config.ip) - return clientCache.getOrPut(key) { - // Note: timeout is not part of the cache key to allow connection reuse - // Use reasonable default timeouts for the client - // Per-request timeout is applied via call.timeout() in performRequest - val defaultTimeout = 60_000L // 60 seconds + synchronized(clientCache) { + clientCache[key]?.let { return it } + + // 60s defaults at the client level; the real deadline is the per-call timeout. + val defaultTimeout = 60_000L - OkHttpClient.Builder() + val client = OkHttpClient.Builder() + .dispatcher(sharedDispatcher) + .connectionPool(sharedConnectionPool) .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS) .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS) .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS) - .callTimeout(0, TimeUnit.MILLISECONDS) // Disable client-level call timeout + .callTimeout(0, TimeUnit.MILLISECONDS) + // TLS is validated normally: cert chain via the default trust manager and + // hostname verification against the REAL hostname (not the pinned IP). .hostnameVerifier { _, session -> HttpsURLConnection.getDefaultHostnameVerifier().verify(config.hostname, session) } .dns(createPinnedDns(config.ip, config.hostname)) .build() + + clientCache[key] = client + return client } } private fun createPinnedDns(ip: String, hostname: String): Dns = object : Dns { private val expectedHost = hostname.lowercase(Locale.US) + // Resolve the literal IP once up front (validated; never triggers DNS). + private val pinnedAddress: InetAddress = SniConnectValidation.literalToInetAddress(ip) override fun lookup(requestedHost: String): List { return if (requestedHost.lowercase(Locale.US) == expectedHost) { - listOf(resolveIp(ip)) + listOf(pinnedAddress) } else { Dns.SYSTEM.lookup(requestedHost) } } } + /** + * Build the request. Always `https://` on the implicit port 443 — + * `path` has been validated as relative, so scheme/host/port cannot be overridden. + */ private fun buildRequest(config: RequestConfig): Request { - val normalizedPath = if (config.path.startsWith("http")) { - config.path - } else { - val prefix = if (config.path.startsWith("/")) "" else "/" - "https://${config.hostname}$prefix${config.path}" - } - - val builder = Request.Builder().url(normalizedPath) + val url = "https://${config.hostname}${config.path}" + val builder = Request.Builder().url(url) config.headers.forEach { (key, value) -> if (!key.equals("host", ignoreCase = true)) { @@ -251,7 +238,7 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } builder.header("Host", config.hostname) - val method = config.method.uppercase(Locale.US) + val method = config.method val bodyContent = config.body ?: "" val mediaType = config.headers.entries .firstOrNull { it.key.equals("Content-Type", ignoreCase = true) } @@ -278,13 +265,10 @@ class SniConnectModule(reactContext: ReactApplicationContext) : } private fun ResponseBody?.safeString(): String { - if (this == null) { - return "" - } + if (this == null) return "" return try { this.string() } catch (error: IOException) { - Log.e(TAG, "[SniConnect] Failed to read response body", error) throw IOException("Failed to read response body", error) } } @@ -297,50 +281,47 @@ class SniConnectModule(reactContext: ReactApplicationContext) : return map } - private fun resolveIp(ip: String): InetAddress { - return try { - InetAddress.getByName(ip) - } catch (error: UnknownHostException) { - throw IOException("Invalid IP address: $ip", error) - } - } - private fun Map.toWritableMap(): WritableMap { return Arguments.createMap().apply { - forEach { (key, value) -> - putString(key, value) - } + forEach { (key, value) -> putString(key, value) } } } private fun ReadableMap.toRequestConfig(): RequestConfig { val headersMap = if (hasKey("headers") && !isNull("headers")) { - val headersReadable = getMap("headers") - headersReadable?.toHashMap() + getMap("headers")?.toHashMap() ?.mapValues { (_, value) -> value?.toString() ?: "" } ?: emptyMap() } else { emptyMap() } - val timeoutMillis = if (hasKey("timeout")) { - (getDouble("timeout") * 1.0).toLong() + val timeoutMillis = if (hasKey("timeout") && !isNull("timeout")) { + getDouble("timeout").toLong().coerceAtLeast(1L) } else { 30_000L } - val requestId = if (hasKey("requestId") && !isNull("requestId")) { - getString("requestId") - } else { - null - } + val requestId = if (hasKey("requestId") && !isNull("requestId")) getString("requestId") else null + + val ip = getString("ip") ?: throw IllegalArgumentException("ip is required") + val hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required") + val method = getString("method") ?: "GET" + val path = getString("path") ?: "/" + + // Validate every caller-controlled field at the boundary. + SniConnectValidation.validatePublicIp(ip) + SniConnectValidation.validateHostname(hostname) + SniConnectValidation.validateHeaders(headersMap) + val normalizedMethod = SniConnectValidation.normalizeMethod(method) + val normalizedPath = SniConnectValidation.normalizePath(path) return RequestConfig( requestId = requestId, - ip = getString("ip") ?: throw IllegalArgumentException("ip is required"), - hostname = getString("hostname") ?: throw IllegalArgumentException("hostname is required"), - method = getString("method") ?: "GET", - path = getString("path") ?: "/", + ip = ip, + hostname = hostname, + method = normalizedMethod, + path = normalizedPath, headers = headersMap, body = if (hasKey("body") && !isNull("body")) getString("body") else null, timeoutMillis = timeoutMillis, diff --git a/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt new file mode 100644 index 00000000..5d1c9a12 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectValidation.kt @@ -0,0 +1,144 @@ +package com.sniconnect + +import java.net.Inet6Address +import java.net.InetAddress +import java.util.Locale + +/** + * Boundary validation/normalization for SNI request inputs. + * + * The module connects to a caller-supplied IP while preserving the TLS SNI/Host of + * `hostname`. Because the connect target is caller-controlled, every field that + * reaches the network layer is validated here to prevent SSRF, scheme/host/port + * override, cleartext downgrade and CR/LF header injection. + */ +internal object SniConnectValidation { + + class ValidationException(message: String) : IllegalArgumentException(message) + + private val ALLOWED_METHODS = + setOf("GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS") + + private val HOSTNAME_REGEX = Regex( + "^(?=.{1,253}\$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\$" + ) + private val IPV4_REGEX = Regex("^(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\$") + private val SCHEME_REGEX = Regex("^[A-Za-z][A-Za-z0-9+.-]*:") + + fun normalizeMethod(method: String): String { + val upper = method.trim().uppercase(Locale.US) + if (upper !in ALLOWED_METHODS) { + throw ValidationException("Invalid method: $method") + } + return upper + } + + fun validateHostname(hostname: String) { + if (hostname.isEmpty() || hostname.length > 253 || !HOSTNAME_REGEX.matches(hostname)) { + throw ValidationException("Invalid hostname: $hostname") + } + } + + /** Must be a relative path/query only — reject absolute/protocol-relative URLs and control chars. */ + fun normalizePath(path: String): String { + val trimmed = path.trim() + if (containsControlChars(trimmed)) { + throw ValidationException("Invalid path") + } + if (trimmed.contains("://") || trimmed.startsWith("//") || SCHEME_REGEX.containsMatchIn(trimmed.take(64).substringBefore('/'))) { + throw ValidationException("Invalid path: absolute URLs are not allowed") + } + if (trimmed.isEmpty()) return "/" + return if (trimmed.startsWith("/")) trimmed else "/$trimmed" + } + + fun validateHeaders(headers: Map) { + for ((key, value) in headers) { + if (key.isEmpty() || containsControlChars(key) || containsControlChars(value)) { + throw ValidationException("Invalid header: $key") + } + } + } + + private fun containsControlChars(s: String): Boolean = + s.any { it.code < 0x20 || it.code == 0x7F } + + /** + * Validate `ip` is a literal IPv4/IPv6 address (never a hostname) routing to a + * public/global-unicast destination. Rejects loopback, private, link-local + * (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved ranges. + */ + fun validatePublicIp(ip: String) { + val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt() } + if (octets != null) { + if (octets.any { it > 255 }) throw ValidationException("Invalid IP: $ip") + if (isForbiddenIpv4(octets)) throw ValidationException("Forbidden IP: $ip") + return + } + // IPv6: only treat as literal if it contains ':' (avoids any DNS lookup). + if (ip.contains(':')) { + val addr: InetAddress = try { + InetAddress.getByName(ip) + } catch (e: Exception) { + throw ValidationException("Invalid IP: $ip") + } + if (addr !is Inet6Address) throw ValidationException("Invalid IP: $ip") + if (isForbiddenIpv6(addr)) throw ValidationException("Forbidden IP: $ip") + return + } + throw ValidationException("Invalid IP: $ip") + } + + private fun isForbiddenIpv4(o: List): Boolean { + val a = o[0]; val b = o[1]; val c = o[2]; val d = o[3] + return when { + a == 0 -> true // 0.0.0.0/8 + a == 10 -> true // 10/8 private + a == 127 -> true // 127/8 loopback + a == 100 && (b and 0xC0) == 0x40 -> true // 100.64/10 CGNAT + a == 169 && b == 254 -> true // 169.254/16 link-local + metadata + a == 172 && b in 16..31 -> true // 172.16/12 private + a == 192 && b == 168 -> true // 192.168/16 private + a == 192 && b == 0 && c == 0 -> true // 192.0.0/24 + a == 192 && b == 0 && c == 2 -> true // 192.0.2/24 TEST-NET-1 + a == 198 && (b == 18 || b == 19) -> true // 198.18/15 benchmarking + a == 198 && b == 51 && c == 100 -> true // 198.51.100/24 TEST-NET-2 + a == 203 && b == 0 && c == 113 -> true // 203.0.113/24 TEST-NET-3 + a >= 224 -> true // 224/4 multicast + 240/4 reserved + broadcast + else -> false + } + } + + private fun isForbiddenIpv6(addr: Inet6Address): Boolean { + if (addr.isAnyLocalAddress || addr.isLoopbackAddress || addr.isLinkLocalAddress || + addr.isSiteLocalAddress || addr.isMulticastAddress + ) { + return true + } + val bytes = addr.address + // Unique local fc00::/7 + if ((bytes[0].toInt() and 0xFE) == 0xFC) return true + // IPv4-mapped ::ffff:a.b.c.d — validate the embedded IPv4 + val mappedPrefixZero = (0..9).all { bytes[it].toInt() == 0 } + if (mappedPrefixZero && (bytes[10].toInt() and 0xFF) == 0xFF && (bytes[11].toInt() and 0xFF) == 0xFF) { + return isForbiddenIpv4( + listOf( + bytes[12].toInt() and 0xFF, + bytes[13].toInt() and 0xFF, + bytes[14].toInt() and 0xFF, + bytes[15].toInt() and 0xFF, + ) + ) + } + return false + } + + /** Parse a validated IPv4/IPv6 literal into an InetAddress without DNS resolution. */ + fun literalToInetAddress(ip: String): InetAddress { + val octets = IPV4_REGEX.matchEntire(ip)?.groupValues?.drop(1)?.map { it.toInt().toByte() } + if (octets != null) { + return InetAddress.getByAddress(byteArrayOf(octets[0], octets[1], octets[2], octets[3])) + } + return InetAddress.getByName(ip) // safe: already validated as an IPv6 literal + } +} diff --git a/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h index 0a583fa9..bf77aa61 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h +++ b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h @@ -6,7 +6,6 @@ // #import -#import #ifdef RCT_NEW_ARCH_ENABLED #import diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.mm b/native-modules/react-native-sni-connect/ios/SniConnect.mm index 336c6545..68e8d4b9 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.mm +++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm @@ -1,5 +1,4 @@ #import -#import #import #ifdef RCT_NEW_ARCH_ENABLED @@ -8,7 +7,7 @@ // Forward declaration of the Swift implementation @interface SniConnectImpl : NSObject -- (instancetype)initWithEventSender:(id)eventSender; +- (instancetype)init; - (void)request:(NSDictionary *)config resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; @@ -21,7 +20,7 @@ - (void)clearDNSCache:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject; @end -@interface SniConnect : RCTEventEmitter +@interface SniConnect : NSObject #ifdef RCT_NEW_ARCH_ENABLED #else @@ -31,63 +30,42 @@ @interface SniConnect : RCTEventEmitter @implementation SniConnect { SniConnectImpl *_implementation; - BOOL _hasListeners; } RCT_EXPORT_MODULE(SniConnect) -// Expose hasListeners as a property for Swift access -- (BOOL)hasListeners { - return _hasListeners; -} - + (BOOL)requiresMainQueueSetup { return NO; } - (instancetype)init { if (self = [super init]) { - _implementation = [[SniConnectImpl alloc] initWithEventSender:self]; - _hasListeners = NO; + _implementation = [[SniConnectImpl alloc] init]; } return self; } -// Event emitter methods -- (NSArray *)supportedEvents { - return @[@"SniConnectLog"]; -} - -- (void)startObserving { - _hasListeners = YES; -} - -- (void)stopObserving { - _hasListeners = NO; -} - -// Method to send log event to JS -- (void)sendLogEvent:(NSDictionary *)logData { - if (_hasListeners) { - [self sendEventWithName:@"SniConnectLog" body:logData]; - } -} - #ifdef RCT_NEW_ARCH_ENABLED // TurboModule interface implementation - (void)request:(JS::NativeSniConnect::SniConnectRequest &)config resolve:(RCTPromiseResolveBlock)resolve reject:(RCTPromiseRejectBlock)reject { - // Convert Codegen struct to NSDictionary for Swift implementation - NSDictionary *configDict = @{ - @"ip": config.ip(), - @"hostname": config.hostname(), - @"method": config.method(), - @"path": config.path(), - @"headers": config.headers(), - @"body": config.body() ?: [NSNull null], - @"timeout": @(config.timeout()) - }; + // Convert Codegen struct to NSDictionary for the Swift implementation. + // Null-guard every field so a nil value never crashes the dictionary literal, + // and forward requestId so cancellation works under the new architecture. + NSMutableDictionary *configDict = [NSMutableDictionary dictionary]; + configDict[@"ip"] = config.ip() ?: @""; + configDict[@"hostname"] = config.hostname() ?: @""; + configDict[@"method"] = config.method() ?: @"GET"; + configDict[@"path"] = config.path() ?: @"/"; + configDict[@"headers"] = config.headers() ?: @{}; + configDict[@"timeout"] = @(config.timeout()); + if (config.requestId()) { + configDict[@"requestId"] = config.requestId(); + } + if (config.body()) { + configDict[@"body"] = config.body(); + } [_implementation request:configDict resolve:resolve reject:reject]; } diff --git a/native-modules/react-native-sni-connect/ios/SniConnect.swift b/native-modules/react-native-sni-connect/ios/SniConnect.swift index 24df23df..2ad98a4b 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnect.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -8,52 +8,25 @@ private enum SniConnectError: Error { @objc(SniConnectImpl) final class SniConnectImpl: NSObject { private let client: SniConnectClient - private weak var eventSender: AnyObject? @objc - init(eventSender: AnyObject) { - self.eventSender = eventSender + override init() { self.client = SniConnectClient() super.init() - - // Set up logging closure for the client - self.client.onLog = { [weak self] level, message in - self?.sendLogEvent(level: level, message: message) - } - } - - private func sendLogEvent(level: String, message: String) { - // Send log event to the Objective-C bridge - guard let eventSender = eventSender else { - return - } - - let logData: [String: Any] = [ - "level": level, - "message": message, - "timestamp": Int(Date().timeIntervalSince1970 * 1000) - ] - - // Call the sendLogEvent method on the bridge - let selector = NSSelectorFromString("sendLogEvent:") - if eventSender.responds(to: selector) { - eventSender.perform(selector, with: logData) - } } @objc public func request( _ config: NSDictionary, - resolve resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock ) { do { let parsed = try Self.parseDictionary(config) handleRequest(config: parsed, resolve: resolve, reject: reject) } catch { - NSLog("[SniConnect] ❌ Config parsing failed: \(error)") - sendLogEvent(level: "error", message: "Config parsing failed: \(error)") - reject("SNI_REQUEST_FAILED", error.localizedDescription, error) + SniConnectLog.error("Config parsing failed: \(error)") + reject("SNI_INVALID_CONFIG", "\(error)", error) } } @@ -67,7 +40,7 @@ final class SniConnectImpl: NSObject { return try await client.performRequest(config: config) } - // Register task synchronously if requestId is provided + // Register task if requestId is provided if let requestId = config.requestId { client.registerTask(task, for: requestId) } @@ -86,16 +59,11 @@ final class SniConnectImpl: NSObject { "multiValueHeaders": result.multiValueHeaders, ]) } catch let error as SniConnectClient.SniConnectError { - NSLog("[SniConnect] ❌ [\(error.code)] \(error.message)") - sendLogEvent(level: "error", message: "[\(error.code)] \(error.message)") reject(error.code, error.message, error) } catch is CancellationError { - NSLog("[SniConnect] ❌ Request cancelled") - sendLogEvent(level: "info", message: "Request cancelled") reject("SNI_CANCELLED", "Request cancelled", nil) } catch { - NSLog("[SniConnect] ❌ Request failed: \(error.localizedDescription)") - sendLogEvent(level: "error", message: "Request failed: \(error.localizedDescription)") + SniConnectLog.error("Request failed: \(error.localizedDescription)") reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error) } } @@ -104,8 +72,8 @@ final class SniConnectImpl: NSObject { @objc public func cancelRequest( _ requestId: String, - resolve resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock ) { client.cancelRequest(requestId: requestId) resolve(["success": true]) @@ -114,7 +82,7 @@ final class SniConnectImpl: NSObject { @objc public func cancelAllRequests( _ resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + reject: @escaping RCTPromiseRejectBlock ) { client.cancelAllRequests() resolve(["success": true]) @@ -123,7 +91,7 @@ final class SniConnectImpl: NSObject { @objc public func clearDNSCache( _ resolve: @escaping RCTPromiseResolveBlock, - reject reject: @escaping RCTPromiseRejectBlock + reject: @escaping RCTPromiseRejectBlock ) { client.clearDNSCache() resolve(["success": true]) @@ -163,23 +131,16 @@ final class SniConnectImpl: NSObject { } private static func serializeResponseData(_ data: Any) -> String { - if let dict = data as? [String: Any], - let jsonData = try? JSONSerialization.data(withJSONObject: dict, options: []), - let jsonString = String(data: jsonData, encoding: .utf8) { - return jsonString + if let stringValue = data as? String { + return stringValue } - if let array = data as? [[String: Any]], - let jsonData = try? JSONSerialization.data(withJSONObject: array, options: []), + if JSONSerialization.isValidJSONObject(data), + let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []), let jsonString = String(data: jsonData, encoding: .utf8) { return jsonString } - if let stringValue = data as? String { - return stringValue - } - return String(describing: data) } } - diff --git a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift index 5bbe57ca..511d0736 100644 --- a/native-modules/react-native-sni-connect/ios/SniConnectClient.swift +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -1,30 +1,34 @@ import Foundation +import UIKit import EMASCurl /// Core HTTPS client that enforces IP direct connection with SNI. final class SniConnectClient { - // Logging callback - var onLog: ((String, String) -> Void)? - // Active requests tracking for cancellation support private var activeTasks: [String: Task] = [:] private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent) + // Token for the memory-warning observer (block-based observers are not removed + // by `removeObserver(self)`, so the token must be retained and removed explicitly). + private var memoryWarningObserver: NSObjectProtocol? + init() { // Register for memory warnings to clean cache - NotificationCenter.default.addObserver( + memoryWarningObserver = NotificationCenter.default.addObserver( forName: UIApplication.didReceiveMemoryWarningNotification, object: nil, queue: .main - ) { [weak self] _ in - self?.onLog?("warning", "Memory warning received, cleaning DNS cache") + ) { _ in + SniConnectLog.info("Memory warning received, cleaning DNS cache") DNSResolver.cleanExpiredEntries() } } deinit { - NotificationCenter.default.removeObserver(self) + if let observer = memoryWarningObserver { + NotificationCenter.default.removeObserver(observer) + } } struct RequestConfig { @@ -164,8 +168,10 @@ final class SniConnectClient { curlConfig.enableBuiltInRedirection = true curlConfig.cacheEnabled = false - // Enable full certificate validation for security - // The certificate will be validated against the SNI hostname, not the IP + // Enable full certificate validation for security. + // The certificate is validated against the SNI hostname, not the IP, because + // the custom DNS resolver only overrides address resolution — libcurl keeps the + // original hostname for SNI and certificate CN/SAN matching. curlConfig.certificateValidationEnabled = true curlConfig.domainNameVerificationEnabled = true curlConfig.dnsResolver = DNSResolver.self @@ -178,88 +184,54 @@ final class SniConnectClient { private static let queue = DispatchQueue(label: "com.onekey.sni.connect.dns", attributes: .concurrent) private static let cache = DNSCache() - /// LRU cache for DNS mappings with size limit and TTL support - /// Uses hostname:IP as cache key to ensure different IPs for the same hostname are isolated - private class DNSCache { - private struct CacheEntry { - let hostname: String + /// Thread-safe hostname -> IP pin with TTL. + /// + /// LIMITATION: EMASCurl only exposes a process-global DNS resolver + /// (`setDNSResolver:`), which receives only the hostname. There is no + /// per-request DNS API, so the pin is keyed by hostname and the most recent + /// IP for a hostname wins. Concurrent requests to the SAME hostname targeting + /// DIFFERENT IPs are therefore not guaranteed to each hit their own IP. Normal + /// usage (one IP per hostname at a time) is unaffected. + private final class DNSCache { + private struct Entry { let ip: String let timestamp: TimeInterval } - private var storage: [String: CacheEntry] = [:] - private var accessOrder: [String] = [] - - // Mapping from hostname to IP for DNS resolution - // This stores the most recently set IP for each hostname - private var hostnameToIP: [String: String] = [:] - - // Maximum cache entries (100 as specified in requirements) + private var hostnameToEntry: [String: Entry] = [:] private let maxSize = 100 - // TTL in seconds (5 minutes default) - private let ttl: TimeInterval = 300 - - /// Generate cache key using hostname:IP format - /// This ensures different IPs for the same hostname are isolated (accurate speed testing) - private func makeCacheKey(hostname: String, ip: String) -> String { - return "\(hostname.lowercased()):\(ip)" - } + private let ttl: TimeInterval = 300 // 5 minutes func get(_ domain: String) -> String? { - let normalizedDomain = domain.lowercased() - - // Return the most recently set IP for this hostname - return hostnameToIP[normalizedDomain] + let key = domain.lowercased() + guard let entry = hostnameToEntry[key] else { return nil } + if Date().timeIntervalSince1970 - entry.timestamp > ttl { + hostnameToEntry.removeValue(forKey: key) + return nil + } + return entry.ip } func set(_ ip: String, for domain: String) { - let normalizedDomain = domain.lowercased() - let key = makeCacheKey(hostname: normalizedDomain, ip: ip) - - // Update hostname to IP mapping - hostnameToIP[normalizedDomain] = ip - - // Evict oldest entry if cache is full - if storage.count >= maxSize && storage[key] == nil { - if let oldest = accessOrder.first { - storage.removeValue(forKey: oldest) - accessOrder.removeFirst() + let key = domain.lowercased() + // Evict the oldest entry when at capacity (and this is a new host). + if hostnameToEntry.count >= maxSize && hostnameToEntry[key] == nil { + if let oldest = hostnameToEntry.min(by: { $0.value.timestamp < $1.value.timestamp })?.key { + hostnameToEntry.removeValue(forKey: oldest) } } - - // Add or update entry - let entry = CacheEntry(hostname: normalizedDomain, ip: ip, timestamp: Date().timeIntervalSince1970) - storage[key] = entry - - // Update access order - if let index = accessOrder.firstIndex(of: key) { - accessOrder.remove(at: index) - } - accessOrder.append(key) - } - - func remove(_ key: String) { - storage.removeValue(forKey: key) - if let index = accessOrder.firstIndex(of: key) { - accessOrder.remove(at: index) - } + hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970) } func clear() { - storage.removeAll() - accessOrder.removeAll() - hostnameToIP.removeAll() + hostnameToEntry.removeAll() } func cleanExpired() { let now = Date().timeIntervalSince1970 - let expiredKeys = storage.filter { now - $0.value.timestamp > ttl }.map { $0.key } - for key in expiredKeys { - remove(key) - // Also remove from hostnameToIP if this was the active mapping - if let entry = storage[key], hostnameToIP[entry.hostname] == entry.ip { - hostnameToIP.removeValue(forKey: entry.hostname) - } + let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key } + for key in expired { + hostnameToEntry.removeValue(forKey: key) } } } @@ -296,19 +268,19 @@ final class SniConnectClient { /// Clear all DNS cache entries func clearDNSCache() { DNSResolver.clearCache() - onLog?("info", "DNS cache cleared") + SniConnectLog.info("DNS cache cleared") } /// Cancel a request by ID func cancelRequest(requestId: String) { tasksQueue.async(flags: .barrier) { [weak self] in guard let task = self?.activeTasks[requestId] else { - self?.onLog?("warning", "No active request found with ID: \(requestId)") + SniConnectLog.warn("No active request found with ID: \(requestId)") return } task.cancel() self?.activeTasks.removeValue(forKey: requestId) - self?.onLog?("info", "Request cancelled: \(requestId)") + SniConnectLog.info("Request cancelled: \(requestId)") } } @@ -321,15 +293,14 @@ final class SniConnectClient { task.cancel() } self.activeTasks.removeAll() - self.onLog?("info", "Cancelled \(count) active requests") + SniConnectLog.info("Cancelled \(count) active requests") } } - /// Register an active task (synchronous for immediate registration) + /// Register an active task (async barrier; the task is already running). func registerTask(_ task: Task, for requestId: String) { - tasksQueue.sync(flags: .barrier) { [weak self] in + tasksQueue.async(flags: .barrier) { [weak self] in self?.activeTasks[requestId] = task - self?.onLog?("info", "Registered request: \(requestId), total active: \(self?.activeTasks.count ?? 0)") } } @@ -338,7 +309,6 @@ final class SniConnectClient { guard let requestId = requestId else { return } tasksQueue.async(flags: .barrier) { [weak self] in self?.activeTasks.removeValue(forKey: requestId) - self?.onLog?("info", "Unregistered request: \(requestId)") } } @@ -350,12 +320,25 @@ final class SniConnectClient { unregisterTask(requestId: config.requestId) } + // Validate every caller-controlled field before it reaches the network layer. + let method: String + let normalizedPath: String + do { + try SniConnectValidation.validatePublicIP(config.ip) + try SniConnectValidation.validateHostname(config.hostname) + try SniConnectValidation.validateHeaders(config.headers) + method = try SniConnectValidation.normalizeMethod(config.method) + normalizedPath = try SniConnectValidation.normalizePath(config.path) + } catch { + throw SniConnectError.invalidConfig("\(error)") + } + DNSResolver.setIP(config.ip, for: config.hostname) - let url = try Self.buildURL(hostname: config.hostname, path: config.path) + let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath) let mutableRequest = NSMutableURLRequest(url: url) - mutableRequest.httpMethod = config.method.uppercased() + mutableRequest.httpMethod = method // Convert milliseconds to seconds for timeout values let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0 @@ -380,15 +363,15 @@ final class SniConnectClient { mutableRequest.httpBody = bodyData } - // Configure connection timeout for EMASCurl - EMASCurlProtocol.setConnectTimeoutInterval(connectTimeoutSeconds) + // Per-request connect timeout (avoids the process-global setter race). + EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds) let request = mutableRequest as URLRequest do { let (data, response) = try await Self.urlSession.data(for: request) guard let httpResponse = response as? HTTPURLResponse else { let errorMsg = "Invalid HTTP response type" - onLog?("error", errorMsg) + SniConnectLog.error(errorMsg) throw SniConnectError.invalidConfig(errorMsg) } @@ -397,11 +380,10 @@ final class SniConnectClient { let (headers, multiValueHeaders) = Self.extractHeaders(from: httpResponse) let statusText = HTTPURLResponse.localizedString(forStatusCode: status) - // Check for HTTP errors (4xx, 5xx) + // 4xx/5xx are returned to JS as a normal response (the caller inspects + // `status`); we only record it for diagnostics. if status >= 400 { - let error = SniConnectError.httpError(code: status, message: statusText) - onLog?("error", "HTTP error: \(error.message)") - // Still return the response for client to handle + SniConnectLog.warn("HTTP \(status) for \(config.hostname)") } return Response( @@ -412,33 +394,33 @@ final class SniConnectClient { multiValueHeaders: multiValueHeaders ) } catch let error as SniConnectError { - onLog?("error", "[\(error.code)] \(error.message)") + SniConnectLog.error("[\(error.code)] \(error.message)") throw error } catch { // Convert generic errors to specific SniConnectError types let sniError = SniConnectError.from(error) - onLog?("error", "[\(sniError.code)] \(sniError.message)") + SniConnectLog.error("[\(sniError.code)] \(sniError.message)") throw sniError } } - private static func buildURL(hostname: String, path: String) throws -> URL { - let trimmedPath = path.trimmingCharacters(in: .whitespacesAndNewlines) - - if let url = URL(string: trimmedPath), url.scheme != nil { - return url - } - - let normalizedPath: String - if trimmedPath.isEmpty { - normalizedPath = "/" - } else if trimmedPath.hasPrefix("/") { - normalizedPath = trimmedPath + /// Build the request URL. Always `https://` on port 443 — the + /// path has already been validated as relative (no scheme/authority), so the + /// caller cannot override scheme, host or port. + private static func buildURL(hostname: String, normalizedPath: String) throws -> URL { + var components = URLComponents() + components.scheme = "https" + components.host = hostname + // `normalizedPath` is "/...optional?query". Split off the query so URLComponents + // percent-encodes each part correctly. + if let queryIndex = normalizedPath.firstIndex(of: "?") { + components.percentEncodedPath = String(normalizedPath[.. Any { guard !data.isEmpty else { - return [:] + return "" } if let jsonObject = try? JSONSerialization.jsonObject(with: data, options: []) { @@ -483,13 +465,7 @@ final class SniConnectClient { for (key, values) in headerGroups { // For backward compatibility, single-value headers use the last value singleValueHeaders[key] = values.last - - // Multi-value headers contain all values - if values.count > 1 { - multiValueHeaders[key] = values - } else { - multiValueHeaders[key] = values - } + multiValueHeaders[key] = values } return (singleValueHeaders, multiValueHeaders) diff --git a/native-modules/react-native-sni-connect/ios/SniConnectLog.swift b/native-modules/react-native-sni-connect/ios/SniConnectLog.swift new file mode 100644 index 00000000..b7e34ae4 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectLog.swift @@ -0,0 +1,29 @@ +import Foundation + +/// Lightweight logging wrapper that dynamically dispatches to OneKeyLog through +/// the Objective-C runtime. +/// +/// Using reflection (instead of `import ReactNativeNativeLogger`) keeps this +/// TurboModule from hard-linking the nitro-based native-logger module. When +/// OneKeyLog is unavailable the logs are silently dropped. Mirrors the Android +/// `SniConnectLogger` and the existing `BTLogger` / `SBLLogger`. +enum SniConnectLog { + private static let tag = "SniConnect" + + // Swift classes are exposed to the ObjC runtime as `Module.ClassName`. + private static let logClass: AnyObject? = + (NSClassFromString("ReactNativeNativeLogger.OneKeyLog") + ?? NSClassFromString("OneKeyLog")) as AnyObject? + + static func debug(_ message: String) { dispatch("debug::", message) } + static func info(_ message: String) { dispatch("info::", message) } + static func warn(_ message: String) { dispatch("warn::", message) } + static func error(_ message: String) { dispatch("error::", message) } + + private static func dispatch(_ selectorName: String, _ message: String) { + guard let cls = logClass else { return } + let sel = NSSelectorFromString(selectorName) + guard cls.responds(to: sel) else { return } + _ = cls.perform(sel, with: tag, with: message) + } +} diff --git a/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift new file mode 100644 index 00000000..82645647 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectValidation.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Boundary validation/normalization for SNI request inputs. +/// +/// The module connects to a caller-supplied IP while preserving the TLS SNI/Host +/// of `hostname`. Because the connect target is caller-controlled, every field that +/// reaches the network layer is validated here to prevent SSRF, scheme/host/port +/// override, cleartext downgrade and CR/LF header injection. +enum SniConnectValidation { + + enum ValidationError: Error { + case invalidIP(String) + case forbiddenIP(String) + case invalidHostname(String) + case invalidMethod(String) + case invalidPath(String) + case invalidHeader(String) + } + + /// HTTP methods the module is allowed to issue. + private static let allowedMethods: Set = [ + "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", + ] + + /// Validate and uppercase the HTTP method. + static func normalizeMethod(_ method: String) throws -> String { + let upper = method.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + guard allowedMethods.contains(upper) else { + throw ValidationError.invalidMethod(method) + } + return upper + } + + /// Validate `hostname` as a DNS host (used for SNI, Host header and cert matching). + static func validateHostname(_ hostname: String) throws { + guard hostname.count <= 253, !hostname.isEmpty else { + throw ValidationError.invalidHostname(hostname) + } + // Labels: 1-63 chars, alphanumeric + hyphen, not starting/ending with hyphen. + let pattern = "^(?=.{1,253}$)([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)(\\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$" + guard hostname.range(of: pattern, options: .regularExpression) != nil else { + throw ValidationError.invalidHostname(hostname) + } + } + + /// Validate `path`: must be a relative path/query only. Reject absolute URLs + /// (scheme/authority), protocol-relative URLs and control characters so the + /// caller cannot override scheme/host/port or downgrade to cleartext. + static func normalizePath(_ path: String) throws -> String { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + if containsControlCharacters(trimmed) { + throw ValidationError.invalidPath(path) + } + // Reject anything that looks like it carries a scheme or authority. + if trimmed.contains("://") || trimmed.hasPrefix("//") { + throw ValidationError.invalidPath(path) + } + // A bare scheme like "javascript:..." has no "//"; reject any leading scheme. + if let schemeRange = trimmed.range(of: "^[A-Za-z][A-Za-z0-9+.-]*:", options: .regularExpression), + schemeRange.lowerBound == trimmed.startIndex { + throw ValidationError.invalidPath(path) + } + if trimmed.isEmpty { return "/" } + return trimmed.hasPrefix("/") ? trimmed : "/" + trimmed + } + + /// Validate header names/values: reject CR/LF/control characters (header + /// injection) and the Host header (the module sets Host itself). + static func validateHeaders(_ headers: [String: String]) throws { + for (key, value) in headers { + if key.isEmpty || containsControlCharacters(key) || containsControlCharacters(value) { + throw ValidationError.invalidHeader(key) + } + } + } + + static func containsControlCharacters(_ s: String) -> Bool { + return s.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F } + } + + // MARK: - IP validation + + /// Validate `ip` is a literal IPv4/IPv6 address (never a hostname) and routes + /// to a public/global-unicast destination. Rejects loopback, private, + /// link-local (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved. + static func validatePublicIP(_ ip: String) throws { + if let v4 = parseIPv4(ip) { + if isForbiddenIPv4(v4) { throw ValidationError.forbiddenIP(ip) } + return + } + if let v6 = parseIPv6(ip) { + if isForbiddenIPv6(v6) { throw ValidationError.forbiddenIP(ip) } + return + } + throw ValidationError.invalidIP(ip) + } + + private static func parseIPv4(_ ip: String) -> [UInt8]? { + var addr = in_addr() + guard ip.withCString({ inet_pton(AF_INET, $0, &addr) }) == 1 else { return nil } + let raw = addr.s_addr.bigEndian + return [ + UInt8((raw >> 24) & 0xFF), + UInt8((raw >> 16) & 0xFF), + UInt8((raw >> 8) & 0xFF), + UInt8(raw & 0xFF), + ] + } + + private static func parseIPv6(_ ip: String) -> [UInt8]? { + var addr = in6_addr() + guard ip.withCString({ inet_pton(AF_INET6, $0, &addr) }) == 1 else { return nil } + return withUnsafeBytes(of: &addr) { Array($0.bindMemory(to: UInt8.self)) } + } + + private static func isForbiddenIPv4(_ b: [UInt8]) -> Bool { + let a = b[0], c = b[1], d = b[2] + if a == 0 { return true } // 0.0.0.0/8 "this network" + if a == 10 { return true } // 10/8 private + if a == 127 { return true } // 127/8 loopback + if a == 100 && (c & 0xC0) == 0x40 { return true } // 100.64/10 CGNAT + if a == 169 && c == 254 { return true } // 169.254/16 link-local + metadata + if a == 172 && c >= 16 && c <= 31 { return true } // 172.16/12 private + if a == 192 && c == 168 { return true } // 192.168/16 private + if a == 192 && c == 0 && d == 0 { return true } // 192.0.0/24 + if a == 192 && c == 0 && d == 2 { return true } // 192.0.2/24 TEST-NET-1 + if a == 198 && (c == 18 || c == 19) { return true } // 198.18/15 benchmarking + if a == 198 && c == 51 && d == 100 { return true } // 198.51.100/24 TEST-NET-2 + if a == 203 && c == 0 && d == 113 { return true } // 203.0.113/24 TEST-NET-3 + if a >= 224 { return true } // 224/4 multicast + 240/4 reserved + broadcast + return false + } + + private static func isForbiddenIPv6(_ b: [UInt8]) -> Bool { + // Unspecified :: + if b.allSatisfy({ $0 == 0 }) { return true } + // Loopback ::1 + if b[0...14].allSatisfy({ $0 == 0 }) && b[15] == 1 { return true } + // Multicast ff00::/8 + if b[0] == 0xFF { return true } + // Link-local fe80::/10 + if b[0] == 0xFE && (b[1] & 0xC0) == 0x80 { return true } + // Unique local fc00::/7 + if (b[0] & 0xFE) == 0xFC { return true } + // IPv4-mapped ::ffff:0:0/96 — validate the embedded IPv4 + if b[0...9].allSatisfy({ $0 == 0 }) && b[10] == 0xFF && b[11] == 0xFF { + return isForbiddenIPv4([b[12], b[13], b[14], b[15]]) + } + return false + } +} diff --git a/native-modules/react-native-sni-connect/scripts/block-ips.sh b/native-modules/react-native-sni-connect/scripts/block-ips.sh deleted file mode 100755 index e11b2240..00000000 --- a/native-modules/react-native-sni-connect/scripts/block-ips.sh +++ /dev/null @@ -1,44 +0,0 @@ -#!/bin/bash - -# 配置要屏蔽的 IP 列表 -BLOCKED_IPS=( - # "216.19.4.106" - "104.18.31.39" - # 在这里添加更多 IP -) - -# pf anchor 文件路径 -PF_ANCHOR_FILE="/etc/pf.anchors/block_ips" -PF_CONF="/etc/pf.conf" - -# 检查是否有 sudo 权限 -if [ "$EUID" -ne 0 ]; then - echo "❌ 需要 sudo 权限运行此脚本" - echo "请使用: sudo $0" - exit 1 -fi - -echo "📝 正在生成 pf 规则..." - -# 生成规则文件 -> "$PF_ANCHOR_FILE" # 清空文件 - -for ip in "${BLOCKED_IPS[@]}"; do - echo "block drop out quick from any to $ip" >> "$PF_ANCHOR_FILE" - echo " ✓ 添加屏蔽规则: $ip" -done - -echo "" -echo "📋 生成的规则文件内容:" -cat "$PF_ANCHOR_FILE" - -echo "" -echo "🔄 重新加载 pf 配置..." -pfctl -f "$PF_CONF" 2>&1 | grep -v "Use of -f option" - -echo "" -echo "✅ 当前生效的规则:" -pfctl -a block_ips -s rules 2>&1 | grep -v "ALTQ" - -echo "" -echo "🎉 完成!已屏蔽 ${#BLOCKED_IPS[@]} 个 IP" diff --git a/native-modules/react-native-sni-connect/scripts/sni-request.js b/native-modules/react-native-sni-connect/scripts/sni-request.js deleted file mode 100644 index 21f512c5..00000000 --- a/native-modules/react-native-sni-connect/scripts/sni-request.js +++ /dev/null @@ -1,74 +0,0 @@ -/** - * SNI Direct IP Connection Test Script - * - * This script demonstrates how to make HTTPS requests using: - * - Direct IP connection (bypassing DNS) - * - SNI (Server Name Indication) with domain name - * - Custom headers for API authentication - */ - -const https = require('https'); - -const options = { - host: '104.18.31.39', // Direct IP connection - // host: '216.19.4.106', - port: 443, - path: '/wallet/v1/account/validate-address?networkId=btc--0&accountAddress=bc1qezh467l5gwkk72v2dx6yj488hlpad8d34u6z2j', - method: 'GET', - servername: 'wallet.onekeytest.com', // CRITICAL: SNI must use domain name for TLS handshake - headers: { - 'Host': 'wallet.onekeytest.com', - 'X-Onekey-Request-ID': 'cc740bab-7cbb-412f-9d9a-1d7b515f601d', - 'X-Onekey-Request-Currency': 'usd', - 'X-Onekey-Request-Locale': 'zh-cn', - 'X-Onekey-Request-Theme': 'light', - 'X-Onekey-Request-Platform': 'android-apk', - 'X-Onekey-Request-Version': '5.16.0', - 'X-Onekey-Request-Build-Number': '2000000000', - 'X-Onekey-Request-Token': 'eyJhbGciOi...', // Truncated token for security - 'X-Onekey-Request-Currency-Value': '1.0', - 'X-Onekey-Instance-Id': '67848a28-b89c-4e0b-8c0f-b87824480d6a', - 'x-onekey-wallet-type': 'hd', - 'x-onekey-hide-asset-details': 'false', - }, -}; - -console.log('🚀 Starting SNI test...'); -console.log(`📡 Connecting to: ${options.host}:${options.port}`); -console.log(`🔐 SNI servername: ${options.servername}`); -console.log(''); - -const req = https.request(options, (res) => { - console.log('✅ Connection successful!'); - console.log(`📊 Status Code: ${res.statusCode}`); - console.log('📋 Response Headers:', JSON.stringify(res.headers, null, 2)); - console.log(''); - - res.setEncoding('utf8'); - let body = ''; - - res.on('data', (chunk) => { - body += chunk; - }); - - res.on('end', () => { - console.log('📦 Response Body:'); - try { - const parsed = JSON.parse(body); - console.log(JSON.stringify(parsed, null, 2)); - } catch (e) { - console.log(body); - } - }); -}); - -req.on('error', (e) => { - console.error('❌ Request failed:', e.message); - console.error('💡 Possible causes:'); - console.error(' - Network connectivity issues'); - console.error(' - Invalid IP address or port'); - console.error(' - SNI configuration mismatch'); - console.error(' - Certificate validation failure'); -}); - -req.end(); diff --git a/native-modules/react-native-sni-connect/scripts/verify-sni.sh b/native-modules/react-native-sni-connect/scripts/verify-sni.sh deleted file mode 100755 index 384f388d..00000000 --- a/native-modules/react-native-sni-connect/scripts/verify-sni.sh +++ /dev/null @@ -1,84 +0,0 @@ -#!/bin/bash - -# SNI Direct IP Connection Verification Tool -# This script captures network traffic to verify that: -# 1. Direct IP connection is established -# 2. SNI (Server Name Indication) is correctly sent during TLS handshake - -# Configuration -TARGET_IP="104.18.31.39" -TARGET_PORT="443" -SNI_HOSTNAME="wallet.onekeytest.com" - -echo "🔍 SNI Direct IP Connection Verification Tool" -echo "==============================================" -echo "" -echo "Target IP: $TARGET_IP:$TARGET_PORT" -echo "SNI Lookup: $SNI_HOSTNAME" -echo "" -echo "⚠️ Prerequisites:" -echo " 1. Your application is running" -echo " 2. Ready to trigger the 'Direct IP Connection' feature" -echo " 3. sudo permission is required for packet capture" -echo "" - -# Check for sudo permission early -if ! sudo -v &>/dev/null; then - echo "❌ Error: sudo permission is required to run tcpdump" - echo "💡 Please run this script with appropriate permissions" - exit 1 -fi - -echo "Press Enter to start monitoring..." -read - -echo "" -echo "🎯 Packet capture started... (Press Ctrl+C to stop)" -echo "==============================================" -echo "" - -# Packet counter -count=0 - -# Capture and analyze packets -# Using -X for hex+ASCII output to better capture binary TLS data -# Using -s 0 to capture full packets (SNI is in early handshake) -sudo tcpdump -i any -n -X -s 0 "host $TARGET_IP and port $TARGET_PORT" 2>/dev/null | while read -r line; do - # Detect TCP connection initiation (SYN) - if [[ $line == *"Flags [S]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then - echo "✅ [TCP] Connection initiated to $TARGET_IP:$TARGET_PORT" - echo "" - fi - - # Detect TCP connection response (SYN-ACK) - if [[ $line == *"Flags [S.]"* ]] && [[ $line == *"$TARGET_IP.$TARGET_PORT"* ]]; then - echo "✅ [TCP] Server acknowledged connection" - echo "" - fi - - # Detect SNI field in TLS handshake (both ASCII and hex patterns) - if [[ $line == *"$SNI_HOSTNAME"* ]]; then - count=$((count + 1)) - echo "" - echo "🎉 [TLS] SNI detected (#$count): $SNI_HOSTNAME" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo " ✓ Direct IP Connection + SNI VERIFIED!" - echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" - echo "" - fi - - # Display packet summary with timestamps (reduced verbosity) - if [[ $line =~ ^[0-9]{2}:[0-9]{2}:[0-9]{2} ]]; then - if [[ $line == *"> $TARGET_IP.$TARGET_PORT"* ]]; then - # Only show initial request packets to reduce noise - if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S]"* ]]; then - echo "→ [SENT] $line" - fi - elif [[ $line == *"$TARGET_IP.$TARGET_PORT >"* ]]; then - # Only show response packets with data - if [[ $line == *"Flags [P.]"* ]] || [[ $line == *"Flags [S.]"* ]]; then - echo "← [RECV] $line" - fi - fi - fi -done diff --git a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts index f009c50d..9d93ebd2 100644 --- a/native-modules/react-native-sni-connect/src/NativeSniConnect.ts +++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts @@ -33,10 +33,6 @@ export interface Spec extends TurboModule { cancelRequest(requestId: string): Promise<{ success: boolean }>; cancelAllRequests(): Promise<{ success: boolean }>; clearDNSCache(): Promise<{ success: boolean }>; - - // Event emitter methods required for NativeEventEmitter - addListener(eventType: string): void; - removeListeners(count: Int32): void; } const LINKING_ERROR = @@ -45,12 +41,7 @@ const LINKING_ERROR = '- You rebuilt the app after installing the package\n' + '- You are not using Expo Go; create a custom dev client instead\n'; -export type SniConnectModule = Spec & { - request(config: SniConnectRequest): Promise; - cancelRequest(requestId: string): Promise<{ success: boolean }>; - cancelAllRequests(): Promise<{ success: boolean }>; - clearDNSCache(): Promise<{ success: boolean }>; -}; +export type SniConnectModule = Spec; const turboModuleResult = TurboModuleRegistry.get('SniConnect'); diff --git a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts index f90cd7b3..cc56b008 100644 --- a/native-modules/react-native-sni-connect/src/__tests__/index.test.ts +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -1,435 +1,66 @@ -import { NativeModules } from 'react-native'; +jest.mock('../NativeSniConnect', () => ({ + __esModule: true, + default: { + request: jest.fn(), + cancelRequest: jest.fn(), + cancelAllRequests: jest.fn(), + clearDNSCache: jest.fn(), + }, +})); + import { - request, - cancelRequest, cancelAllRequests, + cancelRequest, clearDNSCache, - subscribeToLogs, - type LogEntry, - type SniConnectRequest, - type SniConnectResponse, + request, } from '../index'; +import NativeSniConnect, { + type SniConnectRequest, +} from '../NativeSniConnect'; -// Mock React Native modules -jest.mock('react-native', () => ({ - NativeModules: { - SniConnect: { - request: jest.fn(), - cancelRequest: jest.fn(), - cancelAllRequests: jest.fn(), - clearDNSCache: jest.fn(), - addListener: jest.fn(), - removeListeners: jest.fn(), - }, - }, - NativeEventEmitter: jest.fn().mockImplementation(() => ({ - addListener: jest.fn(() => ({ - remove: jest.fn(), - })), - })), - Platform: { - select: jest.fn((obj) => obj.default), - }, - TurboModuleRegistry: { - get: jest.fn(() => null), - }, -})); - -describe('SniConnect Module', () => { - const mockNativeModule = NativeModules.SniConnect; +const mockNativeModule = NativeSniConnect as unknown as { + request: jest.Mock; + cancelRequest: jest.Mock; + cancelAllRequests: jest.Mock; + clearDNSCache: jest.Mock; +}; +describe('react-native-sni-connect public API', () => { beforeEach(() => { jest.clearAllMocks(); }); - describe('request()', () => { - it('should call native request method with correct config', async () => { - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/api/test', - headers: { Authorization: 'Bearer token' }, - timeout: 30000, - }; - - const mockResponse: SniConnectResponse = { - data: '{"success": true}', - status: 200, - statusText: 'OK', - headers: { 'content-type': 'application/json' }, - }; - - mockNativeModule.request.mockResolvedValue(mockResponse); - - const response = await request(config); - - expect(mockNativeModule.request).toHaveBeenCalledWith(config); - expect(response).toEqual(mockResponse); - }); - - it('should handle request with requestId', async () => { - const config: SniConnectRequest = { - requestId: 'test-123', - ip: '1.1.1.1', - hostname: 'example.com', - method: 'POST', - path: '/api/data', - headers: {}, - body: '{"key": "value"}', - timeout: 30000, - }; - - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 201, - statusText: 'Created', - headers: {}, - }); - - await request(config); - - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ - requestId: 'test-123', - }) - ); - }); - - it('should handle request errors', async () => { - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/api/test', - headers: {}, - timeout: 30000, - }; - - const error = new Error('Network error'); - mockNativeModule.request.mockRejectedValue(error); - - await expect(request(config)).rejects.toThrow('Network error'); - }); - - it('should handle different HTTP methods', async () => { - const methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']; - - for (const method of methods) { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method, - path: '/test', - headers: {}, - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ method }) - ); - } - }); - }); - - describe('cancelRequest()', () => { - it('should call native cancelRequest with requestId', async () => { - mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); - - const result = await cancelRequest('test-request-123'); - - expect(mockNativeModule.cancelRequest).toHaveBeenCalledWith( - 'test-request-123' - ); - expect(result).toEqual({ success: true }); - }); - - it('should handle cancellation failure', async () => { - mockNativeModule.cancelRequest.mockResolvedValue({ success: false }); - - const result = await cancelRequest('non-existent'); - - expect(result.success).toBe(false); - }); + it('forwards request() to the native module and returns its result', async () => { + const config: SniConnectRequest = { + ip: '93.184.216.34', + hostname: 'example.com', + method: 'GET', + path: '/', + headers: {}, + timeout: 30_000, + }; + const response = { data: '{}', status: 200, statusText: 'OK', headers: {} }; + mockNativeModule.request.mockResolvedValue(response); + + await expect(request(config)).resolves.toBe(response); + expect(mockNativeModule.request).toHaveBeenCalledWith(config); }); - describe('cancelAllRequests()', () => { - it('should call native cancelAllRequests', async () => { - mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true }); - - const result = await cancelAllRequests(); - - expect(mockNativeModule.cancelAllRequests).toHaveBeenCalled(); - expect(result).toEqual({ success: true }); - }); + it('forwards cancelRequest() with the request id', async () => { + mockNativeModule.cancelRequest.mockResolvedValue({ success: true }); + await expect(cancelRequest('req-1')).resolves.toEqual({ success: true }); + expect(mockNativeModule.cancelRequest).toHaveBeenCalledWith('req-1'); }); - describe('clearDNSCache()', () => { - it('should call native clearDNSCache', async () => { - mockNativeModule.clearDNSCache.mockResolvedValue({ success: true }); - - const result = await clearDNSCache(); - - expect(mockNativeModule.clearDNSCache).toHaveBeenCalled(); - expect(result).toEqual({ success: true }); - }); + it('forwards cancelAllRequests()', async () => { + mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true }); + await expect(cancelAllRequests()).resolves.toEqual({ success: true }); + expect(mockNativeModule.cancelAllRequests).toHaveBeenCalledTimes(1); }); - describe('subscribeToLogs()', () => { - it('should subscribe to log events and return unsubscribe function', () => { - const mockCallback = jest.fn(); - - const unsubscribe = subscribeToLogs(mockCallback); - - // Should return a function - expect(typeof unsubscribe).toBe('function'); - - // Unsubscribe should not throw - expect(() => unsubscribe()).not.toThrow(); - }); - - it('should call callback when log event is received', () => { - const mockCallback = jest.fn(); - - subscribeToLogs(mockCallback); - - // The actual callback invocation would be triggered by native module - // In unit tests, we're just verifying the subscription was set up correctly - expect(mockCallback).not.toHaveBeenCalled(); // Not called immediately - }); - - it('should handle log entry type correctly', () => { - // Type checking test - const logEntry: LogEntry = { - level: 'info', - message: 'Test log message', - timestamp: Date.now(), - }; - - expect(logEntry).toBeDefined(); - expect(logEntry.level).toBe('info'); - expect(logEntry.message).toBe('Test log message'); - expect(typeof logEntry.timestamp).toBe('number'); - }); - }); - - describe('Request Configuration', () => { - it('should handle empty headers', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith(config); - }); - - it('should handle custom headers', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: { - 'Authorization': 'Bearer token', - 'X-Custom-Header': 'custom-value', - 'Content-Type': 'application/json', - }, - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ - headers: expect.objectContaining({ - 'Authorization': 'Bearer token', - 'X-Custom-Header': 'custom-value', - }), - }) - ); - }); - - it('should handle request body', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'POST', - path: '/api/data', - headers: {}, - body: JSON.stringify({ key: 'value', number: 123 }), - timeout: 30000, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.stringContaining('key'), - }) - ); - }); - - it('should handle different timeout values', async () => { - mockNativeModule.request.mockResolvedValue({ - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }); - - const timeouts = [1000, 5000, 30000, 60000]; - - for (const timeout of timeouts) { - const config: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout, - }; - - await request(config); - expect(mockNativeModule.request).toHaveBeenCalledWith( - expect.objectContaining({ timeout }) - ); - } - }); - }); - - describe('Response Handling', () => { - it('should handle JSON response', async () => { - const mockResponse: SniConnectResponse = { - data: JSON.stringify({ result: 'success', count: 42 }), - status: 200, - statusText: 'OK', - headers: { 'content-type': 'application/json' }, - }; - - mockNativeModule.request.mockResolvedValue(mockResponse); - - const response = await request({ - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }); - - expect(response.data).toContain('success'); - expect(response.headers['content-type']).toBe('application/json'); - }); - - it('should handle plain text response', async () => { - const mockResponse: SniConnectResponse = { - data: 'Plain text response', - status: 200, - statusText: 'OK', - headers: { 'content-type': 'text/plain' }, - }; - - mockNativeModule.request.mockResolvedValue(mockResponse); - - const response = await request({ - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }); - - expect(response.data).toBe('Plain text response'); - }); - - it('should handle different status codes', async () => { - const statusCodes = [200, 201, 204, 400, 404, 500]; - - for (const status of statusCodes) { - mockNativeModule.request.mockResolvedValue({ - data: '', - status, - statusText: 'Status', - headers: {}, - }); - - const response = await request({ - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/test', - headers: {}, - timeout: 30000, - }); - - expect(response.status).toBe(status); - } - }); - }); - - describe('Type Exports', () => { - it('should export LogEntry type', () => { - const log: LogEntry = { - level: 'info', - message: 'test', - timestamp: Date.now(), - }; - expect(log).toBeDefined(); - }); - - it('should export SniConnectRequest type', () => { - const req: SniConnectRequest = { - ip: '1.1.1.1', - hostname: 'example.com', - method: 'GET', - path: '/', - headers: {}, - timeout: 30000, - }; - expect(req).toBeDefined(); - }); - - it('should export SniConnectResponse type', () => { - const res: SniConnectResponse = { - data: '', - status: 200, - statusText: 'OK', - headers: {}, - }; - expect(res).toBeDefined(); - }); + it('forwards clearDNSCache()', async () => { + mockNativeModule.clearDNSCache.mockResolvedValue({ success: true }); + await expect(clearDNSCache()).resolves.toEqual({ success: true }); + expect(mockNativeModule.clearDNSCache).toHaveBeenCalledTimes(1); }); }); diff --git a/native-modules/react-native-sni-connect/src/index.tsx b/native-modules/react-native-sni-connect/src/index.tsx index 46efc80d..e75deb12 100644 --- a/native-modules/react-native-sni-connect/src/index.tsx +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -1,28 +1,8 @@ -import { NativeEventEmitter } from 'react-native'; import NativeSniConnect, { type SniConnectRequest, type SniConnectResponse, } from './NativeSniConnect'; -// Log entry type definition -export type LogEntry = { - level: string; - message: string; - timestamp: number; -}; - -// Create event emitter instance -const eventEmitter = new NativeEventEmitter(NativeSniConnect as any); - -// Simple log subscription function -export function subscribeToLogs(callback: (log: LogEntry) => void): () => void { - const subscription = eventEmitter.addListener('SniConnectLog', (log: any) => { - // Type assertion since we know the structure - callback(log as LogEntry); - }); - return () => subscription.remove(); -} - export function request( config: SniConnectRequest ): Promise { diff --git a/yarn.lock b/yarn.lock index b5c20ceb..ed78ef59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2772,6 +2772,7 @@ __metadata: "@onekeyfe/react-native-scroll-guard": "workspace:*" "@onekeyfe/react-native-segment-slider": "workspace:*" "@onekeyfe/react-native-skeleton": "workspace:*" + "@onekeyfe/react-native-sni-connect": "workspace:*" "@onekeyfe/react-native-splash-screen": "workspace:*" "@onekeyfe/react-native-split-bundle-loader": "workspace:*" "@onekeyfe/react-native-tab-view": "workspace:*" @@ -3751,7 +3752,7 @@ __metadata: languageName: unknown linkType: soft -"@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect": +"@onekeyfe/react-native-sni-connect@workspace:*, @onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect": version: 0.0.0-use.local resolution: "@onekeyfe/react-native-sni-connect@workspace:native-modules/react-native-sni-connect" dependencies: