@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.
@@ -0,0 +1,228 @@
1
+ import Foundation
2
+
3
+ enum SniConnectCoreDiagnostics {
4
+ static var warnSink: ((String) -> Void)?
5
+
6
+ static func warn(_ message: String) {
7
+ warnSink?(message)
8
+ }
9
+
10
+ static func event(_ name: String, _ fields: [(String, Any?)]) -> String {
11
+ let normalizedFields: [(String, Any?)] = [("event", name)] + fields
12
+ return normalizedFields
13
+ .map { key, value in "\(key)=\(sanitize(value))" }
14
+ .joined(separator: " ")
15
+ }
16
+
17
+ static func shortHash(_ value: String?) -> String {
18
+ guard let value, !value.isEmpty else {
19
+ return "none"
20
+ }
21
+
22
+ var hash: UInt64 = 1_469_598_103_934_665_603
23
+ for byte in value.utf8 {
24
+ hash ^= UInt64(byte)
25
+ hash &*= 1_099_511_628_211
26
+ }
27
+ return String(String(format: "%016llx", hash).prefix(12))
28
+ }
29
+
30
+ private static func sanitize(_ value: Any?) -> String {
31
+ guard let value else {
32
+ return "none"
33
+ }
34
+ return String(describing: value)
35
+ .replacingOccurrences(of: "\n", with: "_")
36
+ .replacingOccurrences(of: "\r", with: "_")
37
+ .replacingOccurrences(of: " ", with: "_")
38
+ }
39
+ }
40
+
41
+ struct SniConnectResolverConfig: Equatable {
42
+ let hostname: String
43
+ let ip: String
44
+ }
45
+
46
+ enum SniConnectCoreError: Error {
47
+ case resourceLimit(String)
48
+ }
49
+
50
+ final class SniConnectPinnedResolverRegistry {
51
+ static let defaultMaxEntries = 32
52
+
53
+ private let maxEntries: Int
54
+ private var classesByKey: [String: AnyClass] = [:]
55
+ private var configsByClassName: [String: SniConnectResolverConfig] = [:]
56
+ private var referenceCountsByKey: [String: Int] = [:]
57
+ private var reusableClasses: [AnyClass] = []
58
+ private var allocatedClassNames: Set<String> = []
59
+
60
+ init(maxEntries: Int = SniConnectPinnedResolverRegistry.defaultMaxEntries) {
61
+ self.maxEntries = maxEntries
62
+ }
63
+
64
+ var entryCount: Int {
65
+ classesByKey.count
66
+ }
67
+
68
+ var allocatedClassCount: Int {
69
+ allocatedClassNames.count
70
+ }
71
+
72
+ func resolverClass(
73
+ hostname: String,
74
+ ip: String,
75
+ allocateClass: () -> AnyClass
76
+ ) throws -> AnyClass {
77
+ let normalizedHost = hostname.lowercased()
78
+ let key = Self.key(hostname: normalizedHost, ip: ip)
79
+ if let resolverClass = classesByKey[key] {
80
+ referenceCountsByKey[key, default: 0] += 1
81
+ return resolverClass
82
+ }
83
+
84
+ guard classesByKey.count < maxEntries else {
85
+ throw SniConnectCoreError.resourceLimit("Too many cached SNI resolver entries")
86
+ }
87
+
88
+ let resolverClass: AnyClass = reusableClasses.popLast() ?? allocateClass()
89
+ let className = NSStringFromClass(resolverClass)
90
+ allocatedClassNames.insert(className)
91
+ classesByKey[key] = resolverClass
92
+ configsByClassName[className] = SniConnectResolverConfig(hostname: normalizedHost, ip: ip)
93
+ referenceCountsByKey[key] = 1
94
+ return resolverClass
95
+ }
96
+
97
+ func resolve(domain: String, resolverClass: AnyClass) -> String? {
98
+ guard let config = configsByClassName[NSStringFromClass(resolverClass)] else {
99
+ return nil
100
+ }
101
+ guard domain.caseInsensitiveCompare(config.hostname) == .orderedSame else {
102
+ SniConnectCoreDiagnostics.warn(SniConnectCoreDiagnostics.event("sni_pinned_dns_unexpected_host", [
103
+ ("expectedHost", config.hostname),
104
+ ("requestedHostHash", SniConnectCoreDiagnostics.shortHash(domain.lowercased())),
105
+ ("result", "fail_closed"),
106
+ ]))
107
+ return nil
108
+ }
109
+ return config.ip
110
+ }
111
+
112
+ func release(hostname: String, ip: String) {
113
+ let key = Self.key(hostname: hostname.lowercased(), ip: ip)
114
+ guard let referenceCount = referenceCountsByKey[key] else {
115
+ return
116
+ }
117
+ if referenceCount > 1 {
118
+ referenceCountsByKey[key] = referenceCount - 1
119
+ return
120
+ }
121
+
122
+ referenceCountsByKey.removeValue(forKey: key)
123
+ guard let resolverClass = classesByKey.removeValue(forKey: key) else {
124
+ return
125
+ }
126
+ configsByClassName.removeValue(forKey: NSStringFromClass(resolverClass))
127
+ reusableClasses.append(resolverClass)
128
+ }
129
+
130
+ func clear() {
131
+ reusableClasses.append(contentsOf: classesByKey.values)
132
+ classesByKey.removeAll()
133
+ configsByClassName.removeAll()
134
+ referenceCountsByKey.removeAll()
135
+ }
136
+
137
+ private static func key(hostname: String, ip: String) -> String {
138
+ return "\(hostname)|\(ip)"
139
+ }
140
+ }
141
+
142
+ enum SniConnectResponseText {
143
+ static func decode(_ data: Data) -> String {
144
+ guard !data.isEmpty else {
145
+ return ""
146
+ }
147
+ if let text = String(data: data, encoding: .utf8) {
148
+ return text
149
+ }
150
+ return String(decoding: data, as: UTF8.self)
151
+ }
152
+ }
153
+
154
+ enum SniConnectTimeout: Error, Equatable {
155
+ case deadlineExceeded
156
+ }
157
+
158
+ enum SniConnectWallClockDeadline {
159
+ static func run<T>(
160
+ timeoutMilliseconds: TimeInterval,
161
+ operation: @escaping @Sendable () async throws -> T
162
+ ) async throws -> T {
163
+ let timeoutNanoseconds = UInt64(max(1.0, timeoutMilliseconds) * 1_000_000.0)
164
+ return try await withThrowingTaskGroup(of: T.self) { group in
165
+ group.addTask {
166
+ try await operation()
167
+ }
168
+ group.addTask {
169
+ try await Task.sleep(nanoseconds: timeoutNanoseconds)
170
+ throw SniConnectTimeout.deadlineExceeded
171
+ }
172
+
173
+ do {
174
+ guard let result = try await group.next() else {
175
+ throw SniConnectTimeout.deadlineExceeded
176
+ }
177
+ group.cancelAll()
178
+ return result
179
+ } catch {
180
+ group.cancelAll()
181
+ throw error
182
+ }
183
+ }
184
+ }
185
+ }
186
+
187
+ struct SniConnectHeaderMaps: Equatable {
188
+ let singleValueHeaders: [String: String]
189
+ let multiValueHeaders: [String: [String]]
190
+ }
191
+
192
+ enum SniConnectResponseHeaders {
193
+ static func make(rawHeaderFields: [(name: String, value: String)]) -> SniConnectHeaderMaps {
194
+ var singleValueHeaders: [String: String] = [:]
195
+ var multiValueHeaders: [String: [String]] = [:]
196
+
197
+ for header in rawHeaderFields {
198
+ let name = header.name.lowercased()
199
+ guard !name.isEmpty else { continue }
200
+
201
+ singleValueHeaders[name] = header.value
202
+ multiValueHeaders[name, default: []].append(header.value)
203
+ }
204
+
205
+ return SniConnectHeaderMaps(
206
+ singleValueHeaders: singleValueHeaders,
207
+ multiValueHeaders: multiValueHeaders
208
+ )
209
+ }
210
+
211
+ static func make(from headerFields: [AnyHashable: Any]) -> SniConnectHeaderMaps {
212
+ let rawHeaderFields = headerFields.flatMap { key, value -> [(name: String, value: String)] in
213
+ let name = String(describing: key)
214
+ return values(from: value).map { (name: name, value: $0) }
215
+ }
216
+ return make(rawHeaderFields: rawHeaderFields)
217
+ }
218
+
219
+ private static func values(from value: Any) -> [String] {
220
+ if let values = value as? [String] {
221
+ return values
222
+ }
223
+ if let values = value as? NSArray {
224
+ return values.map { String(describing: $0) }
225
+ }
226
+ return [String(describing: value)]
227
+ }
228
+ }
@@ -20,10 +20,49 @@ enum SniConnectLog {
20
20
  static func warn(_ message: String) { dispatch("warn::", message) }
21
21
  static func error(_ message: String) { dispatch("error::", message) }
22
22
 
23
+ static func event(_ name: String, _ fields: [(String, Any?)]) -> String {
24
+ let normalizedFields: [(String, Any?)] = [("event", name)] + fields
25
+ let pairs = normalizedFields.map { key, value in
26
+ "\(key)=\(sanitize(value))"
27
+ }
28
+ return pairs.joined(separator: " ")
29
+ }
30
+
31
+ static func shortHash(_ value: String?) -> String {
32
+ guard let value, !value.isEmpty else {
33
+ return "none"
34
+ }
35
+
36
+ var hash: UInt64 = 1_469_598_103_934_665_603
37
+ for byte in value.utf8 {
38
+ hash ^= UInt64(byte)
39
+ hash &*= 1_099_511_628_211
40
+ }
41
+ return String(String(format: "%016llx", hash).prefix(12))
42
+ }
43
+
44
+ static func elapsedMs(since startedAt: Date) -> Int {
45
+ max(0, Int(Date().timeIntervalSince(startedAt) * 1000))
46
+ }
47
+
48
+ static func ipFamily(_ ip: String) -> String {
49
+ ip.contains(":") ? "ipv6" : "ipv4"
50
+ }
51
+
23
52
  private static func dispatch(_ selectorName: String, _ message: String) {
24
53
  guard let cls = logClass else { return }
25
54
  let sel = NSSelectorFromString(selectorName)
26
55
  guard cls.responds(to: sel) else { return }
27
56
  _ = cls.perform(sel, with: tag, with: message)
28
57
  }
58
+
59
+ private static func sanitize(_ value: Any?) -> String {
60
+ guard let value else {
61
+ return "none"
62
+ }
63
+ return String(describing: value)
64
+ .replacingOccurrences(of: "\n", with: "_")
65
+ .replacingOccurrences(of: "\r", with: "_")
66
+ .replacingOccurrences(of: " ", with: "_")
67
+ }
29
68
  }
@@ -15,15 +15,83 @@ enum SniConnectValidation {
15
15
  case invalidMethod(String)
16
16
  case invalidPath(String)
17
17
  case invalidHeader(String)
18
+ case invalidRequestId(String)
19
+ case invalidTimeout(Double)
20
+ case invalidBody
21
+ case resourceLimit(String)
18
22
  }
19
23
 
24
+ static let maxRequestIdBytes = 128
25
+ static let maxTimeoutMillis = 120_000.0
26
+ static let maxPathBytes = 8 * 1024
27
+ static let maxRequestBodyBytes = 1024 * 1024
28
+ static let maxResponseBodyBytes = 10 * 1024 * 1024
29
+ static let maxHeaderCount = 64
30
+ static let maxHeaderNameBytes = 128
31
+ static let maxHeaderValueBytes = 8 * 1024
32
+ static let maxTotalHeaderBytes = 32 * 1024
33
+ static let maxActiveRequests = 64
34
+ static let maxActiveRequestsPerPair = 16
35
+
20
36
  /// HTTP methods the module is allowed to issue.
21
37
  private static let allowedMethods: Set<String> = [
22
38
  "GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS",
23
39
  ]
24
40
 
41
+ private static let moduleOwnedHeaders: Set<String> = [
42
+ "host",
43
+ "content-length",
44
+ "accept-encoding",
45
+ "x-emascurl-config-id",
46
+ ]
47
+
48
+ private static let unsafeHeaders: Set<String> = [
49
+ "connection",
50
+ "keep-alive",
51
+ "te",
52
+ "trailer",
53
+ "transfer-encoding",
54
+ "upgrade",
55
+ "expect",
56
+ ]
57
+
58
+ private static let headerTokenPattern = "^[!#$%&'*+.^_`|~0-9A-Za-z-]+$"
59
+
60
+ static func validateRequestId(_ requestId: String?) throws {
61
+ guard let requestId = requestId else { return }
62
+ if requestId.isEmpty ||
63
+ containsControlCharacters(requestId) ||
64
+ byteCount(requestId) > maxRequestIdBytes {
65
+ throw ValidationError.invalidRequestId(requestId)
66
+ }
67
+ }
68
+
69
+ static func validateTimeout(_ timeout: Double) throws {
70
+ if !timeout.isFinite || timeout < 1 || timeout > maxTimeoutMillis {
71
+ throw ValidationError.invalidTimeout(timeout)
72
+ }
73
+ }
74
+
75
+ static func validateBody(_ body: String?) throws {
76
+ if let body = body, byteCount(body) > maxRequestBodyBytes {
77
+ throw ValidationError.invalidBody
78
+ }
79
+ }
80
+
81
+ static func validateMethodBody(method: String, body: String?) throws {
82
+ if (method == "GET" || method == "HEAD") && body != nil {
83
+ throw ValidationError.invalidBody
84
+ }
85
+ if (method == "POST" || method == "PUT" || method == "PATCH") && body == nil {
86
+ throw ValidationError.invalidBody
87
+ }
88
+ }
89
+
25
90
  /// Validate and uppercase the HTTP method.
26
91
  static func normalizeMethod(_ method: String) throws -> String {
92
+ if containsControlCharacters(method) {
93
+ throw ValidationError.invalidMethod(method)
94
+ }
27
95
  let upper = method.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
28
96
  guard allowedMethods.contains(upper) else {
29
97
  throw ValidationError.invalidMethod(method)
@@ -41,6 +109,9 @@ enum SniConnectValidation {
41
109
  guard hostname.range(of: pattern, options: .regularExpression) != nil else {
42
110
  throw ValidationError.invalidHostname(hostname)
43
111
  }
112
+ if parseIPv4(hostname) != nil || parseIPv6(hostname) != nil {
113
+ throw ValidationError.invalidHostname(hostname)
114
+ }
44
115
  }
45
116
 
46
117
  /// Validate `path`: must be a relative path/query only. Reject absolute URLs
@@ -51,6 +122,9 @@ enum SniConnectValidation {
51
122
  if containsControlCharacters(trimmed) {
52
123
  throw ValidationError.invalidPath(path)
53
124
  }
125
+ if byteCount(trimmed) > maxPathBytes {
126
+ throw ValidationError.invalidPath(path)
127
+ }
54
128
  // Reject anything that looks like it carries a scheme or authority.
55
129
  if trimmed.contains("://") || trimmed.hasPrefix("//") {
56
130
  throw ValidationError.invalidPath(path)
@@ -67,23 +141,66 @@ enum SniConnectValidation {
67
141
  /// Validate header names/values: reject CR/LF/control characters (header
68
142
  /// injection) and the Host header (the module sets Host itself).
69
143
  static func validateHeaders(_ headers: [String: String]) throws {
144
+ _ = try normalizeHeaders(headers)
145
+ }
146
+
147
+ static func normalizeHeaders(_ headers: [String: String]) throws -> [String: String] {
148
+ if headers.count > maxHeaderCount {
149
+ throw ValidationError.invalidHeader("too many headers")
150
+ }
151
+
152
+ var totalBytes = 0
153
+ var normalizedHeaders: [String: String] = [:]
70
154
  for (key, value) in headers {
71
- if key.isEmpty || containsControlCharacters(key) || containsControlCharacters(value) {
155
+ let keyBytes = byteCount(key)
156
+ let valueBytes = byteCount(value)
157
+ totalBytes += keyBytes + valueBytes
158
+
159
+ if key.isEmpty ||
160
+ containsControlCharacters(key) ||
161
+ containsControlCharacters(value) ||
162
+ keyBytes > maxHeaderNameBytes ||
163
+ valueBytes > maxHeaderValueBytes ||
164
+ key.range(of: headerTokenPattern, options: .regularExpression) == nil {
72
165
  throw ValidationError.invalidHeader(key)
73
166
  }
167
+
168
+ let lowerKey = key.lowercased()
169
+ if lowerKey.hasPrefix(":") || lowerKey.hasPrefix("proxy-") || unsafeHeaders.contains(lowerKey) {
170
+ throw ValidationError.invalidHeader(key)
171
+ }
172
+ if moduleOwnedHeaders.contains(lowerKey) {
173
+ continue
174
+ }
175
+ normalizedHeaders[key] = value
74
176
  }
177
+ if totalBytes > maxTotalHeaderBytes {
178
+ throw ValidationError.invalidHeader("headers too large")
179
+ }
180
+ return normalizedHeaders
75
181
  }
76
182
 
77
183
  static func containsControlCharacters(_ s: String) -> Bool {
78
184
  return s.unicodeScalars.contains { $0.value < 0x20 || $0.value == 0x7F }
79
185
  }
80
186
 
187
+ private static func byteCount(_ s: String) -> Int {
188
+ return s.lengthOfBytes(using: .utf8)
189
+ }
190
+
81
191
  // MARK: - IP validation
82
192
 
83
193
  /// Validate `ip` is a literal IPv4/IPv6 address (never a hostname) and routes
84
194
  /// to a public/global-unicast destination. Rejects loopback, private,
85
195
  /// link-local (incl. 169.254.169.254 metadata), CGNAT, multicast and reserved.
86
196
  static func validatePublicIP(_ ip: String) throws {
197
+ if ip.isEmpty ||
198
+ ip.trimmingCharacters(in: .whitespacesAndNewlines) != ip ||
199
+ ip.contains("[") ||
200
+ ip.contains("]") ||
201
+ ip.contains("%") {
202
+ throw ValidationError.invalidIP(ip)
203
+ }
87
204
  if let v4 = parseIPv4(ip) {
88
205
  if isForbiddenIPv4(v4) { throw ValidationError.forbiddenIP(ip) }
89
206
  return
@@ -142,10 +259,135 @@ enum SniConnectValidation {
142
259
  if b[0] == 0xFE && (b[1] & 0xC0) == 0x80 { return true }
143
260
  // Unique local fc00::/7
144
261
  if (b[0] & 0xFE) == 0xFC { return true }
262
+ // Discard-only 100::/64
263
+ if b[0] == 0x01 && b[1] == 0x00 && b[2...7].allSatisfy({ $0 == 0 }) { return true }
264
+ // IETF protocol assignments that should not be accepted as public endpoints.
265
+ if b[0] == 0x20 && b[1] == 0x01 {
266
+ if b[2] == 0x00 && b[3] == 0x00 { return true } // 2001::/32 Teredo
267
+ if b[2] == 0x00 && (b[3] & 0xF0) == 0x10 { return true } // 2001:10::/28 ORCHID
268
+ if b[2] == 0x00 && b[3] == 0x02 { return true } // 2001:2::/48 benchmarking
269
+ if b[2] == 0x0D && b[3] == 0xB8 { return true } // 2001:db8::/32 docs
270
+ }
271
+ // 6to4 embeds an IPv4 route target and is deprecated.
272
+ if b[0] == 0x20 && b[1] == 0x02 { return true }
273
+ // NAT64 well-known prefix. Allow only when the embedded IPv4 is public.
274
+ if isNat64WellKnown(b) { return isForbiddenIPv4([b[12], b[13], b[14], b[15]]) }
275
+ // NAT64 local-use prefix can route through operator-specific private policy.
276
+ if isNat64LocalUse(b) { return true }
277
+ // Deprecated IPv4-compatible IPv6 addresses.
278
+ if b[0...11].allSatisfy({ $0 == 0 }) { return true }
145
279
  // IPv4-mapped ::ffff:0:0/96 — validate the embedded IPv4
146
280
  if b[0...9].allSatisfy({ $0 == 0 }) && b[10] == 0xFF && b[11] == 0xFF {
147
281
  return isForbiddenIPv4([b[12], b[13], b[14], b[15]])
148
282
  }
149
283
  return false
150
284
  }
285
+
286
+ private static func isNat64WellKnown(_ b: [UInt8]) -> Bool {
287
+ return b[0] == 0x00 &&
288
+ b[1] == 0x64 &&
289
+ b[2] == 0xFF &&
290
+ b[3] == 0x9B &&
291
+ b[4...11].allSatisfy({ $0 == 0 })
292
+ }
293
+
294
+ private static func isNat64LocalUse(_ b: [UInt8]) -> Bool {
295
+ return b[0] == 0x00 &&
296
+ b[1] == 0x64 &&
297
+ b[2] == 0xFF &&
298
+ b[3] == 0x9B &&
299
+ b[4] == 0x00 &&
300
+ b[5] == 0x01
301
+ }
302
+ }
303
+
304
+ final class SniConnectRequestLimiter {
305
+ final class Token {
306
+ private weak var limiter: SniConnectRequestLimiter?
307
+ private let key: String
308
+ private let lock = NSLock()
309
+ private var released = false
310
+
311
+ fileprivate init(limiter: SniConnectRequestLimiter, key: String) {
312
+ self.limiter = limiter
313
+ self.key = key
314
+ }
315
+
316
+ func release() {
317
+ lock.lock()
318
+ if released {
319
+ lock.unlock()
320
+ return
321
+ }
322
+ released = true
323
+ lock.unlock()
324
+ limiter?.release(key: key)
325
+ }
326
+
327
+ deinit {
328
+ release()
329
+ }
330
+ }
331
+
332
+ private let maxActiveRequests: Int
333
+ private let maxActiveRequestsPerPair: Int
334
+ private let queue = DispatchQueue(label: "com.onekey.sni.connect.request-limiter")
335
+ private var activeRequests = 0
336
+ private var activeRequestsByPair: [String: Int] = [:]
337
+
338
+ init(
339
+ maxActiveRequests: Int = SniConnectValidation.maxActiveRequests,
340
+ maxActiveRequestsPerPair: Int = SniConnectValidation.maxActiveRequestsPerPair
341
+ ) {
342
+ self.maxActiveRequests = maxActiveRequests
343
+ self.maxActiveRequestsPerPair = maxActiveRequestsPerPair
344
+ }
345
+
346
+ func acquire(hostname: String, ip: String) throws -> Token {
347
+ let key = pairKey(hostname: hostname, ip: ip)
348
+ return try queue.sync {
349
+ if activeRequests >= maxActiveRequests {
350
+ SniConnectCoreDiagnostics.warn(SniConnectCoreDiagnostics.event("sni_resource_limit", [
351
+ ("activeCount", activeRequests),
352
+ ("pairCount", activeRequestsByPair[key] ?? 0),
353
+ ("limit", maxActiveRequests),
354
+ ("reason", "max_active_requests"),
355
+ ("hostname", hostname.lowercased()),
356
+ ("ipHash", SniConnectCoreDiagnostics.shortHash(ip)),
357
+ ]))
358
+ throw SniConnectValidation.ValidationError.resourceLimit("Too many active SNI requests")
359
+ }
360
+ let pairCount = activeRequestsByPair[key] ?? 0
361
+ if pairCount >= maxActiveRequestsPerPair {
362
+ SniConnectCoreDiagnostics.warn(SniConnectCoreDiagnostics.event("sni_resource_limit", [
363
+ ("activeCount", activeRequests),
364
+ ("pairCount", pairCount),
365
+ ("limit", maxActiveRequestsPerPair),
366
+ ("reason", "max_active_requests_per_pair"),
367
+ ("hostname", hostname.lowercased()),
368
+ ("ipHash", SniConnectCoreDiagnostics.shortHash(ip)),
369
+ ]))
370
+ throw SniConnectValidation.ValidationError.resourceLimit("Too many active SNI requests for destination")
371
+ }
372
+ activeRequests += 1
373
+ activeRequestsByPair[key] = pairCount + 1
374
+ return Token(limiter: self, key: key)
375
+ }
376
+ }
377
+
378
+ private func release(key: String) {
379
+ queue.sync {
380
+ activeRequests = max(0, activeRequests - 1)
381
+ guard let pairCount = activeRequestsByPair[key] else { return }
382
+ if pairCount <= 1 {
383
+ activeRequestsByPair.removeValue(forKey: key)
384
+ } else {
385
+ activeRequestsByPair[key] = pairCount - 1
386
+ }
387
+ }
388
+ }
389
+
390
+ private func pairKey(hostname: String, ip: String) -> String {
391
+ return "\(hostname.lowercased())|\(ip)"
392
+ }
151
393
  }