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 new file mode 100644 index 00000000..099779c5 --- /dev/null +++ b/native-modules/react-native-sni-connect/README.md @@ -0,0 +1,62 @@ +# @onekeyfe/react-native-sni-connect + +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 +yarn add @onekeyfe/react-native-sni-connect +``` + +iOS: run `pod install`. Android autolinks. + +## Usage + +```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 + +- 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 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..8ff6dbf0 --- /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/app-modules/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..efd08421 --- /dev/null +++ b/native-modules/react-native-sni-connect/android/build.gradle @@ -0,0 +1,68 @@ +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") + } + + 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/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 new file mode 100644 index 00000000..a86da5da --- /dev/null +++ b/native-modules/react-native-sni-connect/android/src/main/java/com/sniconnect/SniConnectModule.kt @@ -0,0 +1,341 @@ +package com.sniconnect + +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.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 +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.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" + +@ReactModule(name = SniConnectModule.NAME) +class SniConnectModule(reactContext: ReactApplicationContext) : + NativeSniConnectSpec(reactContext) { + + companion object { + const val NAME = "SniConnect" + + // 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 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, + ) + + // 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 val activeCalls = ConcurrentHashMap() + + override fun getName(): String = NAME + + override fun request(config: ReadableMap, promise: Promise) { + try { + val requestConfig = config.toRequestConfig() + performRequest(requestConfig, promise) + } catch (error: Exception) { + SniConnectLogger.error("Config parsing failed: ${error.message}") + promise.reject("SNI_INVALID_CONFIG", error.message, error) + } + } + + @ReactMethod + override fun cancelRequest(requestId: String, promise: Promise) { + val call = activeCalls.remove(requestId) + if (call != null) { + call.cancel() + SniConnectLogger.info("Cancelled request: $requestId") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) + } else { + 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() + SniConnectLogger.info("Cancelled $count active requests") + promise.resolve(Arguments.createMap().apply { putBoolean("success", true) }) + } + + @ReactMethod + override fun clearDNSCache(promise: Promise) { + 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) { + 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 + } + + // 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) { + config.requestId?.let { activeCalls.remove(it) } + if (!settled.compareAndSet(false, true)) return + + if (call.isCanceled()) { + promise.reject("SNI_CANCELLED", "Request cancelled", null) + } else { + SniConnectLogger.error("Request failed: ${e.message}") + promise.reject("SNI_REQUEST_FAILED", e.message, e) + } + } + + override fun onResponse(call: Call, response: Response) { + config.requestId?.let { activeCalls.remove(it) } + + val result: WritableMap = try { + response.use { + val bodyString = response.body.safeString() + val headerMap = headersToMap(response.headers) + Arguments.createMap().apply { + putString("data", bodyString) + putInt("status", response.code) + putString("statusText", response.message) + putMap("headers", headerMap.toWritableMap()) + } + } + } catch (error: Exception) { + 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) { + SniConnectLogger.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) + + 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 + + val client = OkHttpClient.Builder() + .dispatcher(sharedDispatcher) + .connectionPool(sharedConnectionPool) + .connectTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .readTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .writeTimeout(defaultTimeout, TimeUnit.MILLISECONDS) + .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(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 url = "https://${config.hostname}${config.path}" + val builder = Request.Builder().url(url) + + config.headers.forEach { (key, value) -> + if (!key.equals("host", ignoreCase = true)) { + builder.addHeader(key, value) + } + } + builder.header("Host", config.hostname) + + val method = config.method + 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) { + 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 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")) { + getMap("headers")?.toHashMap() + ?.mapValues { (_, value) -> value?.toString() ?: "" } + ?: emptyMap() + } else { + emptyMap() + } + + 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 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 = ip, + hostname = hostname, + method = normalizedMethod, + path = normalizedPath, + 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/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/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..bf77aa61 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect-Bridging-Header.h @@ -0,0 +1,12 @@ +// +// SniConnect-Bridging-Header.h +// SniConnect +// +// Used to import Objective-C headers that should be visible to Swift. +// + +#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..68e8d4b9 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect.mm @@ -0,0 +1,119 @@ +#import +#import + +#ifdef RCT_NEW_ARCH_ENABLED +#import +#endif + +// Forward declaration of the Swift implementation +@interface SniConnectImpl : NSObject +- (instancetype)init; +- (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 : NSObject +#ifdef RCT_NEW_ARCH_ENABLED + +#else + +#endif +@end + +@implementation SniConnect { + SniConnectImpl *_implementation; +} + +RCT_EXPORT_MODULE(SniConnect) + ++ (BOOL)requiresMainQueueSetup { + return NO; +} + +- (instancetype)init { + if (self = [super init]) { + _implementation = [[SniConnectImpl alloc] init]; + } + return self; +} + +#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 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]; +} + +- (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..2ad98a4b --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnect.swift @@ -0,0 +1,146 @@ +import Foundation +import React + +private enum SniConnectError: Error { + case invalidConfig(String) +} + +@objc(SniConnectImpl) +final class SniConnectImpl: NSObject { + private let client: SniConnectClient + + @objc + override init() { + self.client = SniConnectClient() + super.init() + } + + @objc + public func request( + _ config: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + do { + let parsed = try Self.parseDictionary(config) + handleRequest(config: parsed, resolve: resolve, reject: reject) + } catch { + SniConnectLog.error("Config parsing failed: \(error)") + reject("SNI_INVALID_CONFIG", "\(error)", 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 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 { + reject(error.code, error.message, error) + } catch is CancellationError { + reject("SNI_CANCELLED", "Request cancelled", nil) + } catch { + SniConnectLog.error("Request failed: \(error.localizedDescription)") + reject("SNI_UNKNOWN_ERROR", error.localizedDescription, error) + } + } + } + + @objc + public func cancelRequest( + _ requestId: String, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + client.cancelRequest(requestId: requestId) + resolve(["success": true]) + } + + @objc + public func cancelAllRequests( + _ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock + ) { + client.cancelAllRequests() + resolve(["success": true]) + } + + @objc + public func clearDNSCache( + _ resolve: @escaping RCTPromiseResolveBlock, + 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 stringValue = data as? String { + return stringValue + } + + if JSONSerialization.isValidJSONObject(data), + let jsonData = try? JSONSerialization.data(withJSONObject: data, options: []), + let jsonString = String(data: jsonData, encoding: .utf8) { + return jsonString + } + + 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..511d0736 --- /dev/null +++ b/native-modules/react-native-sni-connect/ios/SniConnectClient.swift @@ -0,0 +1,473 @@ +import Foundation +import UIKit +import EMASCurl + +/// Core HTTPS client that enforces IP direct connection with SNI. +final class SniConnectClient { + + // 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 + memoryWarningObserver = NotificationCenter.default.addObserver( + forName: UIApplication.didReceiveMemoryWarningNotification, + object: nil, + queue: .main + ) { _ in + SniConnectLog.info("Memory warning received, cleaning DNS cache") + DNSResolver.cleanExpiredEntries() + } + } + + deinit { + if let observer = memoryWarningObserver { + NotificationCenter.default.removeObserver(observer) + } + } + + 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 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 + + 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() + + /// 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 hostnameToEntry: [String: Entry] = [:] + private let maxSize = 100 + private let ttl: TimeInterval = 300 // 5 minutes + + func get(_ domain: String) -> String? { + 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 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) + } + } + hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970) + } + + func clear() { + hostnameToEntry.removeAll() + } + + func cleanExpired() { + let now = Date().timeIntervalSince1970 + let expired = hostnameToEntry.filter { now - $0.value.timestamp > ttl }.map { $0.key } + for key in expired { + hostnameToEntry.removeValue(forKey: key) + } + } + } + + @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() + 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 { + SniConnectLog.warn("No active request found with ID: \(requestId)") + return + } + task.cancel() + self?.activeTasks.removeValue(forKey: requestId) + SniConnectLog.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() + SniConnectLog.info("Cancelled \(count) active requests") + } + } + + /// Register an active task (async barrier; the task is already running). + func registerTask(_ task: Task, for requestId: String) { + tasksQueue.async(flags: .barrier) { [weak self] in + self?.activeTasks[requestId] = task + } + } + + /// 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) + } + } + + func performRequest(config: RequestConfig) async throws -> Response { + // Check if task is cancelled + try Task.checkCancellation() + + defer { + 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, normalizedPath: normalizedPath) + + let mutableRequest = NSMutableURLRequest(url: url) + mutableRequest.httpMethod = method + + // 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 + } + + // 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" + SniConnectLog.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) + + // 4xx/5xx are returned to JS as a normal response (the caller inspects + // `status`); we only record it for diagnostics. + if status >= 400 { + SniConnectLog.warn("HTTP \(status) for \(config.hostname)") + } + + return Response( + data: parsedData, + status: status, + statusText: statusText, + headers: headers, + multiValueHeaders: multiValueHeaders + ) + } catch let error as SniConnectError { + SniConnectLog.error("[\(error.code)] \(error.message)") + throw error + } catch { + // Convert generic errors to specific SniConnectError types + let sniError = SniConnectError.from(error) + SniConnectLog.error("[\(sniError.code)] \(sniError.message)") + throw sniError + } + } + + /// 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 "" + } + + 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 + 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/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/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..9d93ebd2 --- /dev/null +++ b/native-modules/react-native-sni-connect/src/NativeSniConnect.ts @@ -0,0 +1,61 @@ +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 }>; +} + +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; + +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..cc56b008 --- /dev/null +++ b/native-modules/react-native-sni-connect/src/__tests__/index.test.ts @@ -0,0 +1,66 @@ +jest.mock('../NativeSniConnect', () => ({ + __esModule: true, + default: { + request: jest.fn(), + cancelRequest: jest.fn(), + cancelAllRequests: jest.fn(), + clearDNSCache: jest.fn(), + }, +})); + +import { + cancelAllRequests, + cancelRequest, + clearDNSCache, + request, +} from '../index'; +import NativeSniConnect, { + type SniConnectRequest, +} from '../NativeSniConnect'; + +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(); + }); + + 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); + }); + + 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'); + }); + + it('forwards cancelAllRequests()', async () => { + mockNativeModule.cancelAllRequests.mockResolvedValue({ success: true }); + await expect(cancelAllRequests()).resolves.toEqual({ success: true }); + expect(mockNativeModule.cancelAllRequests).toHaveBeenCalledTimes(1); + }); + + 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 new file mode 100644 index 00000000..e75deb12 --- /dev/null +++ b/native-modules/react-native-sni-connect/src/index.tsx @@ -0,0 +1,26 @@ +import NativeSniConnect, { + type SniConnectRequest, + type SniConnectResponse, +} from './NativeSniConnect'; + +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..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,6 +3752,39 @@ __metadata: languageName: unknown linkType: soft +"@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: + "@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"