@onekeyfe/react-native-sni-connect 3.0.71 → 3.0.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -1
- package/SniConnect.podspec +2 -2
- package/android/build.gradle +1 -0
- package/android/src/main/java/com/sniconnect/SniConnectLogger.kt +32 -1
- package/android/src/main/java/com/sniconnect/SniConnectModule.kt +508 -68
- package/android/src/main/java/com/sniconnect/SniConnectValidation.kt +259 -13
- package/android/src/test/java/com/sniconnect/SniConnectValidationTest.kt +257 -0
- package/ios/SniConnect.mm +15 -0
- package/ios/SniConnect.swift +119 -22
- package/ios/SniConnectClient.swift +511 -174
- package/ios/SniConnectCore.swift +228 -0
- package/ios/SniConnectLog.swift +39 -0
- package/ios/SniConnectValidation.swift +243 -1
- package/ios/Tests/SniConnectValidationTests/SniConnectValidationTests.swift +325 -0
- package/lib/module/index.js +3 -0
- package/lib/typescript/src/NativeSniConnect.d.ts +25 -4
- package/lib/typescript/src/index.d.ts +2 -1
- package/package.json +9 -1
- package/src/NativeSniConnect.ts +35 -4
- package/src/index.tsx +12 -1
|
@@ -1,27 +1,178 @@
|
|
|
1
1
|
import Foundation
|
|
2
|
+
import ObjectiveC
|
|
2
3
|
import UIKit
|
|
3
4
|
import EMASCurl
|
|
4
5
|
|
|
6
|
+
@objc(SniConnectPinnedDNSResolverBase)
|
|
7
|
+
private class SniConnectPinnedDNSResolverBase: NSObject, EMASCurlProtocolDNSResolver {
|
|
8
|
+
@objc class func resolveDomain(_ domain: String) -> String? {
|
|
9
|
+
PinnedDNSResolverFactory.resolve(domain: domain, resolverClass: self)
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
private enum PinnedDNSResolverFactory {
|
|
14
|
+
private static let queue = DispatchQueue(label: "com.onekey.sni.connect.pinned-dns-resolvers")
|
|
15
|
+
private static var nextClassID = 0
|
|
16
|
+
private static let registry = SniConnectPinnedResolverRegistry()
|
|
17
|
+
|
|
18
|
+
static func resolverClass(hostname: String, ip: String) throws -> EMASCurlProtocolDNSResolver.Type {
|
|
19
|
+
return try queue.sync {
|
|
20
|
+
let resolverClass = try registry.resolverClass(
|
|
21
|
+
hostname: hostname,
|
|
22
|
+
ip: ip,
|
|
23
|
+
allocateClass: allocateResolverClass
|
|
24
|
+
)
|
|
25
|
+
return resolverClass as! EMASCurlProtocolDNSResolver.Type
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
static func resolve(domain: String, resolverClass: AnyClass) -> String? {
|
|
30
|
+
return queue.sync {
|
|
31
|
+
registry.resolve(domain: domain, resolverClass: resolverClass)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
static func remove(hostname: String, ip: String) {
|
|
36
|
+
queue.sync {
|
|
37
|
+
registry.release(hostname: hostname, ip: ip)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private static func allocateResolverClass() -> AnyClass {
|
|
42
|
+
while true {
|
|
43
|
+
nextClassID += 1
|
|
44
|
+
let className = "SniConnectPinnedDNSResolver_\(nextClassID)"
|
|
45
|
+
if let resolverClass = objc_allocateClassPair(
|
|
46
|
+
SniConnectPinnedDNSResolverBase.self,
|
|
47
|
+
className,
|
|
48
|
+
0
|
|
49
|
+
) {
|
|
50
|
+
objc_registerClassPair(resolverClass)
|
|
51
|
+
return resolverClass
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private final class SniConnectPinnedResolverLease {
|
|
58
|
+
private let hostname: String
|
|
59
|
+
private let ip: String
|
|
60
|
+
private let queue = DispatchQueue(label: "com.onekey.sni.connect.resolver-lease")
|
|
61
|
+
private var didRelease = false
|
|
62
|
+
|
|
63
|
+
init(hostname: String, ip: String) {
|
|
64
|
+
self.hostname = hostname
|
|
65
|
+
self.ip = ip
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func release() -> Bool {
|
|
69
|
+
queue.sync {
|
|
70
|
+
if didRelease {
|
|
71
|
+
return false
|
|
72
|
+
}
|
|
73
|
+
PinnedDNSResolverFactory.remove(hostname: hostname, ip: ip)
|
|
74
|
+
didRelease = true
|
|
75
|
+
return true
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func waitUntilReleased() async throws {
|
|
80
|
+
while !isReleased {
|
|
81
|
+
try Task.checkCancellation()
|
|
82
|
+
try await Task.sleep(nanoseconds: 10_000_000)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private var isReleased: Bool {
|
|
87
|
+
queue.sync {
|
|
88
|
+
didRelease
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private final class SniConnectSessionInvalidationDelegate: NSObject, URLSessionDelegate {
|
|
94
|
+
private let hostname: String
|
|
95
|
+
private let ip: String
|
|
96
|
+
private let resolverLease: SniConnectPinnedResolverLease
|
|
97
|
+
|
|
98
|
+
init(hostname: String, ip: String, resolverLease: SniConnectPinnedResolverLease) {
|
|
99
|
+
self.hostname = hostname
|
|
100
|
+
self.ip = ip
|
|
101
|
+
self.resolverLease = resolverLease
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
func urlSession(_ session: URLSession, didBecomeInvalidWithError error: Error?) {
|
|
105
|
+
let releasedResolver = resolverLease.release()
|
|
106
|
+
SniConnectLog.info(SniConnectLog.event("sni_session_invalidated", [
|
|
107
|
+
("hostname", hostname),
|
|
108
|
+
("ipHash", SniConnectLog.shortHash(ip)),
|
|
109
|
+
("releasedResolver", releasedResolver),
|
|
110
|
+
("success", error == nil),
|
|
111
|
+
]))
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
5
115
|
/// Core HTTPS client that enforces IP direct connection with SNI.
|
|
6
116
|
final class SniConnectClient {
|
|
7
117
|
|
|
8
|
-
// Active requests tracking for cancellation support
|
|
9
|
-
|
|
118
|
+
// Active requests tracking for cancellation support. Every task is tracked by
|
|
119
|
+
// token so cancelAllRequests() also covers requests that do not have a requestId.
|
|
120
|
+
private var activeTasksByToken: [UUID: Task<Response, Error>] = [:]
|
|
121
|
+
private var requestTokensById: [String: UUID] = [:]
|
|
10
122
|
private let tasksQueue = DispatchQueue(label: "com.onekey.sni.connect.tasks", attributes: .concurrent)
|
|
123
|
+
private let requestLimiter = SniConnectRequestLimiter()
|
|
124
|
+
|
|
125
|
+
private struct SessionKey: Hashable {
|
|
126
|
+
let hostname: String
|
|
127
|
+
let ip: String
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private struct SessionLease {
|
|
131
|
+
let session: URLSession
|
|
132
|
+
let sessionID: ObjectIdentifier
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private struct ManagedSession {
|
|
136
|
+
let session: URLSession
|
|
137
|
+
let resolverLease: SniConnectPinnedResolverLease
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private struct SessionInvalidation {
|
|
141
|
+
let managedSession: ManagedSession
|
|
142
|
+
let releaseResolverImmediately: Bool
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
private enum SessionAcquisition {
|
|
146
|
+
case ready(SessionLease)
|
|
147
|
+
case waitForResolverSlots([SniConnectPinnedResolverLease])
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private static let maxCachedSessions = SniConnectPinnedResolverRegistry.defaultMaxEntries
|
|
151
|
+
private var sessionCache: [SessionKey: ManagedSession] = [:]
|
|
152
|
+
private var sessionAccessOrder: [SessionKey] = []
|
|
153
|
+
private var activeSessionCounts: [ObjectIdentifier: Int] = [:]
|
|
154
|
+
private let sessionsQueue = DispatchQueue(label: "com.onekey.sni.connect.sessions")
|
|
11
155
|
|
|
12
156
|
// Token for the memory-warning observer (block-based observers are not removed
|
|
13
157
|
// by `removeObserver(self)`, so the token must be retained and removed explicitly).
|
|
14
158
|
private var memoryWarningObserver: NSObjectProtocol?
|
|
15
159
|
|
|
16
160
|
init() {
|
|
161
|
+
SniConnectCoreDiagnostics.warnSink = { message in
|
|
162
|
+
SniConnectLog.warn(message)
|
|
163
|
+
}
|
|
164
|
+
|
|
17
165
|
// Register for memory warnings to clean cache
|
|
18
166
|
memoryWarningObserver = NotificationCenter.default.addObserver(
|
|
19
167
|
forName: UIApplication.didReceiveMemoryWarningNotification,
|
|
20
168
|
object: nil,
|
|
21
169
|
queue: .main
|
|
22
|
-
) { _ in
|
|
23
|
-
SniConnectLog.info("
|
|
24
|
-
|
|
170
|
+
) { [weak self] _ in
|
|
171
|
+
SniConnectLog.info(SniConnectLog.event("sni_lifecycle", [
|
|
172
|
+
("action", "memory_warning"),
|
|
173
|
+
("cacheAction", "clear_dns_cache"),
|
|
174
|
+
]))
|
|
175
|
+
self?.clearDNSCache()
|
|
25
176
|
}
|
|
26
177
|
}
|
|
27
178
|
|
|
@@ -55,7 +206,7 @@ final class SniConnectClient {
|
|
|
55
206
|
}
|
|
56
207
|
|
|
57
208
|
struct Response {
|
|
58
|
-
let data:
|
|
209
|
+
let data: String
|
|
59
210
|
let status: Int
|
|
60
211
|
let statusText: String
|
|
61
212
|
let headers: [String: String] // Single-value headers (backward compatible)
|
|
@@ -72,7 +223,9 @@ final class SniConnectClient {
|
|
|
72
223
|
case connectionRefused
|
|
73
224
|
case networkUnreachable
|
|
74
225
|
case requestTimeout
|
|
226
|
+
case resourceLimit(String)
|
|
75
227
|
case httpError(code: Int, message: String)
|
|
228
|
+
case responseProcessingFailed(String)
|
|
76
229
|
case cancelled
|
|
77
230
|
case unknown(Error)
|
|
78
231
|
|
|
@@ -87,7 +240,9 @@ final class SniConnectClient {
|
|
|
87
240
|
case .connectionRefused: return "SNI_CONNECTION_REFUSED"
|
|
88
241
|
case .networkUnreachable: return "SNI_NETWORK_UNREACHABLE"
|
|
89
242
|
case .requestTimeout: return "SNI_REQUEST_TIMEOUT"
|
|
243
|
+
case .resourceLimit: return "SNI_RESOURCE_LIMIT"
|
|
90
244
|
case .httpError: return "SNI_HTTP_ERROR"
|
|
245
|
+
case .responseProcessingFailed: return "SNI_RESPONSE_FAILED"
|
|
91
246
|
case .cancelled: return "SNI_CANCELLED"
|
|
92
247
|
case .unknown: return "SNI_UNKNOWN_ERROR"
|
|
93
248
|
}
|
|
@@ -113,8 +268,12 @@ final class SniConnectClient {
|
|
|
113
268
|
return "Network unreachable"
|
|
114
269
|
case .requestTimeout:
|
|
115
270
|
return "Request timeout"
|
|
271
|
+
case .resourceLimit(let details):
|
|
272
|
+
return "Resource limit exceeded: \(details)"
|
|
116
273
|
case .httpError(let code, let message):
|
|
117
274
|
return "HTTP error \(code): \(message)"
|
|
275
|
+
case .responseProcessingFailed(let details):
|
|
276
|
+
return "Response processing failed: \(details)"
|
|
118
277
|
case .cancelled:
|
|
119
278
|
return "Request cancelled"
|
|
120
279
|
case .unknown(let error):
|
|
@@ -124,6 +283,27 @@ final class SniConnectClient {
|
|
|
124
283
|
|
|
125
284
|
/// Convert NSError to SniConnectError with detailed classification
|
|
126
285
|
static func from(_ error: Error) -> SniConnectError {
|
|
286
|
+
if let timeout = error as? SniConnectTimeout, timeout == .deadlineExceeded {
|
|
287
|
+
return .requestTimeout
|
|
288
|
+
}
|
|
289
|
+
if error is CancellationError {
|
|
290
|
+
return .cancelled
|
|
291
|
+
}
|
|
292
|
+
if let coreError = error as? SniConnectCoreError {
|
|
293
|
+
switch coreError {
|
|
294
|
+
case .resourceLimit(let details):
|
|
295
|
+
return .resourceLimit(details)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if let validationError = error as? SniConnectValidation.ValidationError {
|
|
299
|
+
switch validationError {
|
|
300
|
+
case .resourceLimit(let details):
|
|
301
|
+
return .resourceLimit(details)
|
|
302
|
+
default:
|
|
303
|
+
break
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
127
307
|
let nsError = error as NSError
|
|
128
308
|
|
|
129
309
|
// Check for URL-related errors
|
|
@@ -153,19 +333,20 @@ final class SniConnectClient {
|
|
|
153
333
|
}
|
|
154
334
|
}
|
|
155
335
|
|
|
156
|
-
private static
|
|
336
|
+
private static func makeURLSession(for key: SessionKey) throws -> ManagedSession {
|
|
157
337
|
let configuration = URLSessionConfiguration.default
|
|
158
338
|
configuration.requestCachePolicy = .reloadIgnoringLocalCacheData
|
|
159
339
|
configuration.urlCache = nil
|
|
160
340
|
configuration.httpCookieStorage = nil
|
|
161
341
|
configuration.httpShouldSetCookies = false
|
|
342
|
+
configuration.connectionProxyDictionary = [:]
|
|
162
343
|
configuration.shouldUseExtendedBackgroundIdleMode = false
|
|
163
344
|
|
|
164
345
|
let curlConfig = EMASCurlConfiguration.default()
|
|
165
|
-
curlConfig.httpVersion = .
|
|
346
|
+
curlConfig.httpVersion = .HTTP1
|
|
166
347
|
curlConfig.connectTimeoutInterval = 2.5
|
|
167
|
-
curlConfig.enableBuiltInGzip =
|
|
168
|
-
curlConfig.enableBuiltInRedirection =
|
|
348
|
+
curlConfig.enableBuiltInGzip = false
|
|
349
|
+
curlConfig.enableBuiltInRedirection = false
|
|
169
350
|
curlConfig.cacheEnabled = false
|
|
170
351
|
|
|
171
352
|
// Enable full certificate validation for security.
|
|
@@ -174,165 +355,287 @@ final class SniConnectClient {
|
|
|
174
355
|
// original hostname for SNI and certificate CN/SAN matching.
|
|
175
356
|
curlConfig.certificateValidationEnabled = true
|
|
176
357
|
curlConfig.domainNameVerificationEnabled = true
|
|
177
|
-
curlConfig.dnsResolver =
|
|
358
|
+
curlConfig.dnsResolver = try PinnedDNSResolverFactory.resolverClass(
|
|
359
|
+
hostname: key.hostname,
|
|
360
|
+
ip: key.ip
|
|
361
|
+
)
|
|
178
362
|
|
|
179
363
|
EMASCurlProtocol.install(into: configuration, with: curlConfig)
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
private var hostnameToEntry: [String: Entry] = [:]
|
|
202
|
-
private let maxSize = 100
|
|
203
|
-
private let ttl: TimeInterval = 300 // 5 minutes
|
|
364
|
+
SniConnectLog.info(SniConnectLog.event("sni_transport_config", [
|
|
365
|
+
("hostname", key.hostname),
|
|
366
|
+
("ipHash", SniConnectLog.shortHash(key.ip)),
|
|
367
|
+
("ipFamily", SniConnectLog.ipFamily(key.ip)),
|
|
368
|
+
("proxyMode", "no_proxy"),
|
|
369
|
+
("pinnedResolver", true),
|
|
370
|
+
("protocol", "http1"),
|
|
371
|
+
("followRedirects", false),
|
|
372
|
+
("cacheEnabled", false),
|
|
373
|
+
]))
|
|
374
|
+
let resolverLease = SniConnectPinnedResolverLease(hostname: key.hostname, ip: key.ip)
|
|
375
|
+
let delegate = SniConnectSessionInvalidationDelegate(
|
|
376
|
+
hostname: key.hostname,
|
|
377
|
+
ip: key.ip,
|
|
378
|
+
resolverLease: resolverLease
|
|
379
|
+
)
|
|
380
|
+
return ManagedSession(
|
|
381
|
+
session: URLSession(configuration: configuration, delegate: delegate, delegateQueue: nil),
|
|
382
|
+
resolverLease: resolverLease
|
|
383
|
+
)
|
|
384
|
+
}
|
|
204
385
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
386
|
+
private func sessionLease(for config: RequestConfig) async throws -> SessionLease {
|
|
387
|
+
let key = SessionKey(hostname: config.hostname.lowercased(), ip: config.ip)
|
|
388
|
+
while true {
|
|
389
|
+
let acquisition = try sessionsQueue.sync { () throws -> SessionAcquisition in
|
|
390
|
+
if let managedSession = sessionCache[key] {
|
|
391
|
+
markSessionUsed(key)
|
|
392
|
+
return .ready(retainSession(managedSession.session))
|
|
210
393
|
}
|
|
211
|
-
return entry.ip
|
|
212
|
-
}
|
|
213
394
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
if hostnameToEntry.count >= maxSize && hostnameToEntry[key] == nil {
|
|
218
|
-
if let oldest = hostnameToEntry.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
|
219
|
-
hostnameToEntry.removeValue(forKey: oldest)
|
|
220
|
-
}
|
|
395
|
+
let pendingResolverSlots = evictSessionsIfNeeded(forPendingInsert: true)
|
|
396
|
+
if !pendingResolverSlots.isEmpty {
|
|
397
|
+
return .waitForResolverSlots(pendingResolverSlots)
|
|
221
398
|
}
|
|
222
|
-
hostnameToEntry[key] = Entry(ip: ip, timestamp: Date().timeIntervalSince1970)
|
|
223
|
-
}
|
|
224
399
|
|
|
225
|
-
|
|
226
|
-
|
|
400
|
+
let managedSession = try Self.makeURLSession(for: key)
|
|
401
|
+
sessionCache[key] = managedSession
|
|
402
|
+
markSessionUsed(key)
|
|
403
|
+
return .ready(retainSession(managedSession.session))
|
|
227
404
|
}
|
|
228
405
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
406
|
+
switch acquisition {
|
|
407
|
+
case .ready(let lease):
|
|
408
|
+
return lease
|
|
409
|
+
case .waitForResolverSlots(let resolverLeases):
|
|
410
|
+
SniConnectLog.info(SniConnectLog.event("sni_resolver_slot_wait", [
|
|
411
|
+
("hostname", key.hostname),
|
|
412
|
+
("ipHash", SniConnectLog.shortHash(key.ip)),
|
|
413
|
+
("waitCount", resolverLeases.count),
|
|
414
|
+
]))
|
|
415
|
+
for resolverLease in resolverLeases {
|
|
416
|
+
try await resolverLease.waitUntilReleased()
|
|
234
417
|
}
|
|
235
418
|
}
|
|
236
419
|
}
|
|
420
|
+
}
|
|
237
421
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
return result
|
|
244
|
-
}
|
|
422
|
+
private func retainSession(_ session: URLSession) -> SessionLease {
|
|
423
|
+
let sessionID = ObjectIdentifier(session)
|
|
424
|
+
activeSessionCounts[sessionID, default: 0] += 1
|
|
425
|
+
return SessionLease(session: session, sessionID: sessionID)
|
|
426
|
+
}
|
|
245
427
|
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
428
|
+
private func releaseSessionLease(_ lease: SessionLease) {
|
|
429
|
+
sessionsQueue.sync {
|
|
430
|
+
guard let count = activeSessionCounts[lease.sessionID] else {
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if count > 1 {
|
|
434
|
+
activeSessionCounts[lease.sessionID] = count - 1
|
|
435
|
+
} else {
|
|
436
|
+
activeSessionCounts.removeValue(forKey: lease.sessionID)
|
|
249
437
|
}
|
|
250
438
|
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private func markSessionUsed(_ key: SessionKey) {
|
|
442
|
+
sessionAccessOrder.removeAll { $0 == key }
|
|
443
|
+
sessionAccessOrder.append(key)
|
|
444
|
+
}
|
|
251
445
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
446
|
+
private func evictSessionsIfNeeded(forPendingInsert: Bool = false) -> [SniConnectPinnedResolverLease] {
|
|
447
|
+
var pendingResolverSlots: [SniConnectPinnedResolverLease] = []
|
|
448
|
+
let limit = forPendingInsert ? Self.maxCachedSessions - 1 : Self.maxCachedSessions
|
|
449
|
+
while sessionCache.count > limit, let evictedKey = sessionAccessOrder.first {
|
|
450
|
+
sessionAccessOrder.removeFirst()
|
|
451
|
+
if let managedSession = sessionCache.removeValue(forKey: evictedKey) {
|
|
452
|
+
if let pendingResolverSlot = invalidateSession(managedSession, for: evictedKey) {
|
|
453
|
+
pendingResolverSlots.append(pendingResolverSlot)
|
|
454
|
+
}
|
|
256
455
|
}
|
|
456
|
+
SniConnectLog.info(SniConnectLog.event("sni_cache_evict", [
|
|
457
|
+
("hostname", evictedKey.hostname),
|
|
458
|
+
("ipHash", SniConnectLog.shortHash(evictedKey.ip)),
|
|
459
|
+
("cacheSize", sessionCache.count),
|
|
460
|
+
("limit", Self.maxCachedSessions),
|
|
461
|
+
("reason", forPendingInsert ? "max_cached_sessions_pending_insert" : "max_cached_sessions"),
|
|
462
|
+
]))
|
|
257
463
|
}
|
|
464
|
+
return pendingResolverSlots
|
|
465
|
+
}
|
|
258
466
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
467
|
+
private func invalidateSession(
|
|
468
|
+
_ managedSession: ManagedSession,
|
|
469
|
+
for key: SessionKey
|
|
470
|
+
) -> SniConnectPinnedResolverLease? {
|
|
471
|
+
let releaseResolverImmediately = activeSessionCounts[ObjectIdentifier(managedSession.session)] == nil
|
|
472
|
+
managedSession.session.finishTasksAndInvalidate()
|
|
473
|
+
if releaseResolverImmediately {
|
|
474
|
+
_ = managedSession.resolverLease.release()
|
|
475
|
+
return nil
|
|
264
476
|
}
|
|
477
|
+
return managedSession.resolverLease
|
|
265
478
|
}
|
|
266
479
|
|
|
267
|
-
///
|
|
480
|
+
/// Drop cached sessions so future requests cannot reuse keep-alive connections
|
|
481
|
+
/// from a previously pinned destination. Idle sessions release their resolver
|
|
482
|
+
/// slot immediately; sessions with active requests release it when invalidation
|
|
483
|
+
/// completes.
|
|
268
484
|
func clearDNSCache() {
|
|
269
|
-
|
|
270
|
-
|
|
485
|
+
let invalidations = sessionsQueue.sync { () -> [SessionInvalidation] in
|
|
486
|
+
let invalidations = sessionCache.map { _, managedSession in
|
|
487
|
+
SessionInvalidation(
|
|
488
|
+
managedSession: managedSession,
|
|
489
|
+
releaseResolverImmediately: activeSessionCounts[ObjectIdentifier(managedSession.session)] == nil
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
sessionCache.removeAll()
|
|
493
|
+
sessionAccessOrder.removeAll()
|
|
494
|
+
return invalidations
|
|
495
|
+
}
|
|
496
|
+
var releasedResolverCount = 0
|
|
497
|
+
for invalidation in invalidations {
|
|
498
|
+
invalidation.managedSession.session.finishTasksAndInvalidate()
|
|
499
|
+
if invalidation.releaseResolverImmediately {
|
|
500
|
+
if invalidation.managedSession.resolverLease.release() {
|
|
501
|
+
releasedResolverCount += 1
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
SniConnectLog.info(SniConnectLog.event("sni_lifecycle", [
|
|
506
|
+
("action", "clear_dns_cache"),
|
|
507
|
+
("sessionCount", invalidations.count),
|
|
508
|
+
("releasedResolverCount", releasedResolverCount),
|
|
509
|
+
("success", true),
|
|
510
|
+
]))
|
|
271
511
|
}
|
|
272
512
|
|
|
273
513
|
/// Cancel a request by ID
|
|
274
|
-
func cancelRequest(requestId: String) {
|
|
275
|
-
tasksQueue.
|
|
276
|
-
guard let
|
|
277
|
-
|
|
278
|
-
|
|
514
|
+
func cancelRequest(requestId: String) -> Bool {
|
|
515
|
+
return tasksQueue.sync(flags: .barrier) { [weak self] in
|
|
516
|
+
guard let self = self, let token = self.requestTokensById.removeValue(forKey: requestId),
|
|
517
|
+
let task = self.activeTasksByToken.removeValue(forKey: token) else {
|
|
518
|
+
SniConnectLog.warn(SniConnectLog.event("sni_cancel", [
|
|
519
|
+
("requestIdHash", SniConnectLog.shortHash(requestId)),
|
|
520
|
+
("success", false),
|
|
521
|
+
]))
|
|
522
|
+
return false
|
|
279
523
|
}
|
|
280
524
|
task.cancel()
|
|
281
|
-
|
|
282
|
-
|
|
525
|
+
SniConnectLog.info(SniConnectLog.event("sni_cancel", [
|
|
526
|
+
("requestIdHash", SniConnectLog.shortHash(requestId)),
|
|
527
|
+
("success", true),
|
|
528
|
+
]))
|
|
529
|
+
return true
|
|
283
530
|
}
|
|
284
531
|
}
|
|
285
532
|
|
|
286
533
|
/// Cancel all active requests
|
|
287
534
|
func cancelAllRequests() {
|
|
288
|
-
tasksQueue.
|
|
535
|
+
tasksQueue.sync(flags: .barrier) { [weak self] in
|
|
289
536
|
guard let self = self else { return }
|
|
290
|
-
let
|
|
291
|
-
for
|
|
537
|
+
let tasks = Array(self.activeTasksByToken.values)
|
|
538
|
+
for task in tasks {
|
|
292
539
|
task.cancel()
|
|
293
540
|
}
|
|
294
|
-
self.
|
|
295
|
-
|
|
541
|
+
self.activeTasksByToken.removeAll()
|
|
542
|
+
self.requestTokensById.removeAll()
|
|
543
|
+
SniConnectLog.info(SniConnectLog.event("sni_cancel_all", [
|
|
544
|
+
("cancelledCount", tasks.count),
|
|
545
|
+
("success", true),
|
|
546
|
+
]))
|
|
296
547
|
}
|
|
297
548
|
}
|
|
298
549
|
|
|
299
550
|
/// Register an active task immediately after creation, before JS can cancel it.
|
|
300
|
-
func registerTask(_ task: Task<Response, Error>, for requestId: String) {
|
|
551
|
+
func registerTask(_ task: Task<Response, Error>, for requestId: String?) -> UUID {
|
|
552
|
+
let token = UUID()
|
|
301
553
|
tasksQueue.sync(flags: .barrier) { [weak self] in
|
|
302
|
-
self
|
|
554
|
+
guard let self = self else { return }
|
|
555
|
+
if let requestId = requestId, let previousToken = self.requestTokensById[requestId],
|
|
556
|
+
let previousTask = self.activeTasksByToken.removeValue(forKey: previousToken) {
|
|
557
|
+
previousTask.cancel()
|
|
558
|
+
SniConnectLog.warn(SniConnectLog.event("sni_duplicate_request_id", [
|
|
559
|
+
("requestIdHash", SniConnectLog.shortHash(requestId)),
|
|
560
|
+
("action", "cancel_previous"),
|
|
561
|
+
]))
|
|
562
|
+
}
|
|
563
|
+
self.activeTasksByToken[token] = task
|
|
564
|
+
if let requestId = requestId {
|
|
565
|
+
self.requestTokensById[requestId] = token
|
|
566
|
+
}
|
|
303
567
|
}
|
|
568
|
+
return token
|
|
304
569
|
}
|
|
305
570
|
|
|
306
571
|
/// Unregister a completed or failed task
|
|
307
|
-
|
|
308
|
-
guard let requestId = requestId else { return }
|
|
572
|
+
func unregisterTask(requestId: String?, token: UUID) {
|
|
309
573
|
tasksQueue.async(flags: .barrier) { [weak self] in
|
|
310
|
-
self?.
|
|
574
|
+
self?.activeTasksByToken.removeValue(forKey: token)
|
|
575
|
+
if let requestId = requestId, self?.requestTokensById[requestId] == token {
|
|
576
|
+
self?.requestTokensById.removeValue(forKey: requestId)
|
|
577
|
+
}
|
|
311
578
|
}
|
|
312
579
|
}
|
|
313
580
|
|
|
314
581
|
func performRequest(config: RequestConfig) async throws -> Response {
|
|
582
|
+
let startedAt = Date()
|
|
315
583
|
// Check if task is cancelled
|
|
316
584
|
try Task.checkCancellation()
|
|
317
585
|
|
|
318
|
-
defer {
|
|
319
|
-
unregisterTask(requestId: config.requestId)
|
|
320
|
-
}
|
|
321
|
-
|
|
322
586
|
// Validate every caller-controlled field before it reaches the network layer.
|
|
323
587
|
let method: String
|
|
324
588
|
let normalizedPath: String
|
|
589
|
+
let normalizedHeaders: [String: String]
|
|
325
590
|
do {
|
|
591
|
+
try SniConnectValidation.validateRequestId(config.requestId)
|
|
326
592
|
try SniConnectValidation.validatePublicIP(config.ip)
|
|
327
593
|
try SniConnectValidation.validateHostname(config.hostname)
|
|
328
|
-
try SniConnectValidation.
|
|
594
|
+
normalizedHeaders = try SniConnectValidation.normalizeHeaders(config.headers)
|
|
329
595
|
method = try SniConnectValidation.normalizeMethod(config.method)
|
|
330
596
|
normalizedPath = try SniConnectValidation.normalizePath(config.path)
|
|
597
|
+
try SniConnectValidation.validateTimeout(config.effectiveTotalTimeout)
|
|
598
|
+
try SniConnectValidation.validateBody(config.body)
|
|
599
|
+
try SniConnectValidation.validateMethodBody(method: method, body: config.body)
|
|
331
600
|
} catch {
|
|
332
|
-
|
|
601
|
+
let sniError = SniConnectError.invalidConfig("\(error)")
|
|
602
|
+
SniConnectLog.error(SniConnectLog.event("sni_request_result", [
|
|
603
|
+
("result", "error"),
|
|
604
|
+
("code", sniError.code),
|
|
605
|
+
("nativeErrorClass", String(describing: type(of: error))),
|
|
606
|
+
("requestIdHash", SniConnectLog.shortHash(config.requestId)),
|
|
607
|
+
("hostname", config.hostname.lowercased()),
|
|
608
|
+
("ipHash", SniConnectLog.shortHash(config.ip)),
|
|
609
|
+
("ipFamily", SniConnectLog.ipFamily(config.ip)),
|
|
610
|
+
("method", config.method.uppercased()),
|
|
611
|
+
("timeoutMs", Int(config.effectiveTotalTimeout)),
|
|
612
|
+
("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
|
|
613
|
+
]))
|
|
614
|
+
throw sniError
|
|
333
615
|
}
|
|
334
616
|
|
|
335
|
-
|
|
617
|
+
let requestSlot: SniConnectRequestLimiter.Token
|
|
618
|
+
do {
|
|
619
|
+
requestSlot = try requestLimiter.acquire(hostname: config.hostname, ip: config.ip)
|
|
620
|
+
} catch {
|
|
621
|
+
let sniError = SniConnectError.resourceLimit("\(error)")
|
|
622
|
+
SniConnectLog.error(SniConnectLog.event("sni_request_result", [
|
|
623
|
+
("result", "error"),
|
|
624
|
+
("code", sniError.code),
|
|
625
|
+
("nativeErrorClass", String(describing: type(of: error))),
|
|
626
|
+
("requestIdHash", SniConnectLog.shortHash(config.requestId)),
|
|
627
|
+
("hostname", config.hostname.lowercased()),
|
|
628
|
+
("ipHash", SniConnectLog.shortHash(config.ip)),
|
|
629
|
+
("ipFamily", SniConnectLog.ipFamily(config.ip)),
|
|
630
|
+
("method", method),
|
|
631
|
+
("timeoutMs", Int(config.effectiveTotalTimeout)),
|
|
632
|
+
("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
|
|
633
|
+
]))
|
|
634
|
+
throw sniError
|
|
635
|
+
}
|
|
636
|
+
defer {
|
|
637
|
+
requestSlot.release()
|
|
638
|
+
}
|
|
336
639
|
|
|
337
640
|
let url = try Self.buildURL(hostname: config.hostname, normalizedPath: normalizedPath)
|
|
338
641
|
|
|
@@ -343,20 +646,17 @@ final class SniConnectClient {
|
|
|
343
646
|
let totalTimeoutSeconds = config.effectiveTotalTimeout / 1000.0
|
|
344
647
|
let connectTimeoutSeconds = config.effectiveConnectTimeout / 1000.0
|
|
345
648
|
|
|
346
|
-
//
|
|
649
|
+
// URLRequest.timeoutInterval is not a full request deadline. Keep it aligned
|
|
650
|
+
// with the caller timeout as a transport guard; the wall-clock deadline below
|
|
651
|
+
// enforces total request time including response body reads.
|
|
347
652
|
mutableRequest.timeoutInterval = totalTimeoutSeconds
|
|
348
653
|
mutableRequest.cachePolicy = .reloadIgnoringLocalCacheData
|
|
349
654
|
|
|
350
|
-
|
|
351
|
-
mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host")
|
|
352
|
-
|
|
353
|
-
for (key, value) in config.headers {
|
|
354
|
-
if key.caseInsensitiveCompare("host") == .orderedSame {
|
|
355
|
-
// Host header is already set above, skip duplicate
|
|
356
|
-
continue
|
|
357
|
-
}
|
|
655
|
+
for (key, value) in normalizedHeaders {
|
|
358
656
|
mutableRequest.setValue(value, forHTTPHeaderField: key)
|
|
359
657
|
}
|
|
658
|
+
mutableRequest.setValue(config.hostname, forHTTPHeaderField: "Host")
|
|
659
|
+
mutableRequest.setValue("identity", forHTTPHeaderField: "Accept-Encoding")
|
|
360
660
|
|
|
361
661
|
if let bodyString = config.body, let bodyData = bodyString.data(using: .utf8) {
|
|
362
662
|
mutableRequest.httpBody = bodyData
|
|
@@ -366,39 +666,95 @@ final class SniConnectClient {
|
|
|
366
666
|
EMASCurlProtocol.setConnectTimeoutIntervalFor(mutableRequest, connectTimeoutInterval: connectTimeoutSeconds)
|
|
367
667
|
let request = mutableRequest as URLRequest
|
|
368
668
|
|
|
669
|
+
SniConnectLog.info(SniConnectLog.event("sni_request_start", [
|
|
670
|
+
("requestIdHash", SniConnectLog.shortHash(config.requestId)),
|
|
671
|
+
("hostname", config.hostname.lowercased()),
|
|
672
|
+
("ipHash", SniConnectLog.shortHash(config.ip)),
|
|
673
|
+
("ipFamily", SniConnectLog.ipFamily(config.ip)),
|
|
674
|
+
("method", method),
|
|
675
|
+
("timeoutMs", Int(config.effectiveTotalTimeout)),
|
|
676
|
+
("connectTimeoutMs", Int(config.effectiveConnectTimeout)),
|
|
677
|
+
("headerCount", normalizedHeaders.count),
|
|
678
|
+
("bodyBytes", config.body?.data(using: .utf8)?.count ?? 0),
|
|
679
|
+
]))
|
|
680
|
+
|
|
369
681
|
do {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
682
|
+
return try await SniConnectWallClockDeadline.run(timeoutMilliseconds: config.effectiveTotalTimeout) {
|
|
683
|
+
let lease = try await sessionLease(for: config)
|
|
684
|
+
defer {
|
|
685
|
+
releaseSessionLease(lease)
|
|
686
|
+
}
|
|
687
|
+
let (bodyBytes, response) = try await lease.session.bytes(for: request)
|
|
688
|
+
guard let httpResponse = response as? HTTPURLResponse else {
|
|
689
|
+
throw SniConnectError.responseProcessingFailed("Invalid HTTP response type")
|
|
690
|
+
}
|
|
376
691
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
692
|
+
let status = httpResponse.statusCode
|
|
693
|
+
let data = try await Self.readResponseBody(
|
|
694
|
+
bodyBytes,
|
|
695
|
+
expectedLength: httpResponse.expectedContentLength
|
|
696
|
+
)
|
|
697
|
+
let responseText = SniConnectResponseText.decode(data)
|
|
698
|
+
let headerMaps = SniConnectResponseHeaders.make(from: httpResponse.allHeaderFields)
|
|
699
|
+
let statusText = ""
|
|
700
|
+
|
|
701
|
+
// 4xx/5xx are returned to JS as a normal response (the caller inspects
|
|
702
|
+
// `status`); we only record it for diagnostics.
|
|
703
|
+
let resultLog = SniConnectLog.event("sni_request_result", [
|
|
704
|
+
("result", "response"),
|
|
705
|
+
("status", status),
|
|
706
|
+
("requestIdHash", SniConnectLog.shortHash(config.requestId)),
|
|
707
|
+
("hostname", config.hostname.lowercased()),
|
|
708
|
+
("ipHash", SniConnectLog.shortHash(config.ip)),
|
|
709
|
+
("ipFamily", SniConnectLog.ipFamily(config.ip)),
|
|
710
|
+
("method", method),
|
|
711
|
+
("timeoutMs", Int(config.effectiveTotalTimeout)),
|
|
712
|
+
("responseBytes", data.count),
|
|
713
|
+
("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
|
|
714
|
+
])
|
|
715
|
+
if status >= 400 {
|
|
716
|
+
SniConnectLog.warn(resultLog)
|
|
717
|
+
} else {
|
|
718
|
+
SniConnectLog.info(resultLog)
|
|
719
|
+
}
|
|
381
720
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
721
|
+
return Response(
|
|
722
|
+
data: responseText,
|
|
723
|
+
status: status,
|
|
724
|
+
statusText: statusText,
|
|
725
|
+
headers: headerMaps.singleValueHeaders,
|
|
726
|
+
multiValueHeaders: headerMaps.multiValueHeaders
|
|
727
|
+
)
|
|
386
728
|
}
|
|
387
|
-
|
|
388
|
-
return Response(
|
|
389
|
-
data: parsedData,
|
|
390
|
-
status: status,
|
|
391
|
-
statusText: statusText,
|
|
392
|
-
headers: headers,
|
|
393
|
-
multiValueHeaders: multiValueHeaders
|
|
394
|
-
)
|
|
395
729
|
} catch let error as SniConnectError {
|
|
396
|
-
SniConnectLog.error(
|
|
730
|
+
SniConnectLog.error(SniConnectLog.event("sni_request_result", [
|
|
731
|
+
("result", "error"),
|
|
732
|
+
("code", error.code),
|
|
733
|
+
("nativeErrorClass", String(describing: type(of: error))),
|
|
734
|
+
("requestIdHash", SniConnectLog.shortHash(config.requestId)),
|
|
735
|
+
("hostname", config.hostname.lowercased()),
|
|
736
|
+
("ipHash", SniConnectLog.shortHash(config.ip)),
|
|
737
|
+
("ipFamily", SniConnectLog.ipFamily(config.ip)),
|
|
738
|
+
("method", method),
|
|
739
|
+
("timeoutMs", Int(config.effectiveTotalTimeout)),
|
|
740
|
+
("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
|
|
741
|
+
]))
|
|
397
742
|
throw error
|
|
398
743
|
} catch {
|
|
399
744
|
// Convert generic errors to specific SniConnectError types
|
|
400
745
|
let sniError = SniConnectError.from(error)
|
|
401
|
-
SniConnectLog.error(
|
|
746
|
+
SniConnectLog.error(SniConnectLog.event("sni_request_result", [
|
|
747
|
+
("result", "error"),
|
|
748
|
+
("code", sniError.code),
|
|
749
|
+
("nativeErrorClass", String(describing: type(of: error))),
|
|
750
|
+
("requestIdHash", SniConnectLog.shortHash(config.requestId)),
|
|
751
|
+
("hostname", config.hostname.lowercased()),
|
|
752
|
+
("ipHash", SniConnectLog.shortHash(config.ip)),
|
|
753
|
+
("ipFamily", SniConnectLog.ipFamily(config.ip)),
|
|
754
|
+
("method", method),
|
|
755
|
+
("timeoutMs", Int(config.effectiveTotalTimeout)),
|
|
756
|
+
("elapsedMs", SniConnectLog.elapsedMs(since: startedAt)),
|
|
757
|
+
]))
|
|
402
758
|
throw sniError
|
|
403
759
|
}
|
|
404
760
|
}
|
|
@@ -425,48 +781,29 @@ final class SniConnectClient {
|
|
|
425
781
|
return url
|
|
426
782
|
}
|
|
427
783
|
|
|
428
|
-
private static func
|
|
429
|
-
|
|
430
|
-
|
|
784
|
+
private static func readResponseBody(
|
|
785
|
+
_ bodyBytes: URLSession.AsyncBytes,
|
|
786
|
+
expectedLength: Int64
|
|
787
|
+
) async throws -> Data {
|
|
788
|
+
let maxBytes = SniConnectValidation.maxResponseBodyBytes
|
|
789
|
+
if expectedLength > Int64(maxBytes) {
|
|
790
|
+
throw SniConnectError.responseProcessingFailed("Response body too large")
|
|
431
791
|
}
|
|
432
792
|
|
|
433
|
-
|
|
434
|
-
|
|
793
|
+
var data = Data()
|
|
794
|
+
if expectedLength > 0 {
|
|
795
|
+
data.reserveCapacity(min(Int(expectedLength), maxBytes))
|
|
435
796
|
}
|
|
436
797
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
return data.base64EncodedString()
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/// Extract headers from HTTP response
|
|
445
|
-
/// Returns both single-value headers (for backward compatibility) and multi-value headers
|
|
446
|
-
private static func extractHeaders(from response: HTTPURLResponse) -> ([String: String], [String: [String]]) {
|
|
447
|
-
var singleValueHeaders: [String: String] = [:]
|
|
448
|
-
var multiValueHeaders: [String: [String]] = [:]
|
|
449
|
-
|
|
450
|
-
// Group headers by normalized key (lowercase)
|
|
451
|
-
var headerGroups: [String: [String]] = [:]
|
|
452
|
-
|
|
453
|
-
for (key, value) in response.allHeaderFields {
|
|
454
|
-
let headerKey = String(describing: key).lowercased()
|
|
455
|
-
let headerValue = String(describing: value)
|
|
456
|
-
|
|
457
|
-
if headerGroups[headerKey] == nil {
|
|
458
|
-
headerGroups[headerKey] = []
|
|
798
|
+
for try await byte in bodyBytes {
|
|
799
|
+
try Task.checkCancellation()
|
|
800
|
+
if data.count >= maxBytes {
|
|
801
|
+
throw SniConnectError.responseProcessingFailed("Response body too large")
|
|
459
802
|
}
|
|
460
|
-
|
|
803
|
+
var nextByte = byte
|
|
804
|
+
data.append(&nextByte, count: 1)
|
|
461
805
|
}
|
|
462
|
-
|
|
463
|
-
// Process grouped headers
|
|
464
|
-
for (key, values) in headerGroups {
|
|
465
|
-
// For backward compatibility, single-value headers use the last value
|
|
466
|
-
singleValueHeaders[key] = values.last
|
|
467
|
-
multiValueHeaders[key] = values
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
return (singleValueHeaders, multiValueHeaders)
|
|
806
|
+
return data
|
|
471
807
|
}
|
|
808
|
+
|
|
472
809
|
}
|