krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v1.7.0: DomainDig/DNSLookupService.swift · raw
1import Foundation
2
3enum DNSResolverOption: String, CaseIterable, Identifiable {
4 case cloudflare
5 case google
6 case quad9
7 case custom
8
9 static let userDefaultsKey = "dnsResolverURL"
10 static let defaultURLString = "https://cloudflare-dns.com/dns-query"
11
12 var id: String { rawValue }
13
14 var title: String {
15 switch self {
16 case .cloudflare: return "Cloudflare"
17 case .google: return "Google"
18 case .quad9: return "Quad9"
19 case .custom: return "Custom"
20 }
21 }
22
23 var urlString: String? {
24 switch self {
25 case .cloudflare: return Self.defaultURLString
26 case .google: return "https://dns.google/dns-query"
27 case .quad9: return "https://dns.quad9.net/dns-query"
28 case .custom: return nil
29 }
30 }
31
32 static func option(for urlString: String) -> DNSResolverOption {
33 let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
34 return Self.allCases.first(where: { $0.urlString == trimmedURL }) ?? .custom
35 }
36
37 static func isValidCustomURL(_ urlString: String) -> Bool {
38 let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
39 guard trimmedURL.hasPrefix("https://") else {
40 return false
41 }
42 return URL(string: trimmedURL) != nil
43 }
44
45 static func resolvedURLString(from storedValue: String?) -> String {
46 guard let storedValue else {
47 return defaultURLString
48 }
49
50 let trimmedURL = storedValue.trimmingCharacters(in: .whitespacesAndNewlines)
51 guard !trimmedURL.isEmpty else {
52 return defaultURLString
53 }
54
55 return isValidCustomURL(trimmedURL) ? trimmedURL : defaultURLString
56 }
57}
58
59struct DNSLookupService {
60 private static let rrsigQueryType = 46
61 private static let dnskeyQueryType = 48
62 private static let internetClass = 1
63
64 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
65 try await lookup(
66 domain: domain,
67 recordType: recordType,
68 resolverURLString: currentResolverURLString()
69 )
70 }
71
72 static func lookup(
73 domain: String,
74 recordType: DNSRecordType,
75 resolverURLString: String
76 ) async throws -> [DNSRecord] {
77 let response = try await lookupResponse(
78 domain: domain,
79 queryType: recordType.queryType,
80 resolverURLString: resolverURLString
81 )
82
83 return response.answers
84 .filter { $0.type == recordType.queryType }
85 .map { answer in
86 let value: String
87 if recordType.usesRawDataValue {
88 value = answer.data
89 } else {
90 value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
91 }
92 return DNSRecord(value: value, ttl: answer.TTL)
93 }
94 }
95
96 static func lookupAll(domain: String) async -> [DNSSection] {
97 typealias Result = (
98 type: DNSRecordType,
99 records: [DNSRecord],
100 wildcard: [DNSRecord],
101 dnssecSigned: Bool?,
102 error: String?
103 )
104
105 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA]
106 let resolverURLString = currentResolverURLString()
107 let dnssecSigned = try? await lookupDNSSECStatus(
108 domain: domain,
109 resolverURLString: resolverURLString
110 )
111
112 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
113 for recordType in DNSRecordType.allCases {
114 let shouldQueryWildcard = wildcardTypes.contains(recordType)
115 group.addTask {
116 var apexRecords: [DNSRecord] = []
117 var wildcardRecords: [DNSRecord] = []
118 var lookupError: String?
119
120 do {
121 apexRecords = try await lookup(
122 domain: domain,
123 recordType: recordType,
124 resolverURLString: resolverURLString
125 )
126 } catch {
127 lookupError = error.localizedDescription
128 }
129
130 if shouldQueryWildcard && lookupError == nil {
131 do {
132 wildcardRecords = try await lookup(
133 domain: "*.\(domain)",
134 recordType: recordType,
135 resolverURLString: resolverURLString
136 )
137 } catch {
138 // Wildcard failure is non-fatal; just leave empty.
139 }
140 }
141
142 return (recordType, apexRecords, wildcardRecords, dnssecSigned, lookupError)
143 }
144 }
145
146 var sections: [DNSSection] = []
147 for await result in group {
148 sections.append(DNSSection(
149 recordType: result.type,
150 records: result.records,
151 wildcardRecords: result.wildcard,
152 dnssecSigned: result.dnssecSigned,
153 error: result.error
154 ))
155 }
156
157 let order = DNSRecordType.allCases
158 return sections.sorted { a, b in
159 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
160 }
161 }
162 }
163
164 private static func lookupResponse(
165 domain: String,
166 queryType: Int,
167 resolverURLString: String,
168 includeDNSSECData: Bool = false
169 ) async throws -> DNSLookupResponse {
170 let resolverURL = try validatedResolverURL(from: resolverURLString)
171 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
172 components.queryItems = [
173 URLQueryItem(name: "name", value: domain),
174 URLQueryItem(name: "type", value: String(queryType))
175 ]
176 if includeDNSSECData {
177 components.queryItems?.append(URLQueryItem(name: "do", value: "1"))
178 }
179
180 var request = URLRequest(url: components.url!)
181 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
182
183 let (data, response) = try await URLSession.shared.data(for: request)
184
185 guard let httpResponse = response as? HTTPURLResponse,
186 httpResponse.statusCode == 200 else {
187 return try await lookupResponseViaRFC8484(
188 domain: domain,
189 queryType: queryType,
190 resolverURL: resolverURL,
191 includeDNSSECData: includeDNSSECData
192 )
193 }
194
195 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
196
197 return DNSLookupResponse(
198 answers: dnsResponse.Answer ?? [],
199 authenticatedData: dnsResponse.AD ?? false
200 )
201 }
202
203 private static func lookupDNSSECStatus(
204 domain: String,
205 resolverURLString: String
206 ) async throws -> Bool {
207 // Query SOA with the DNSSEC OK bit set. The resolver validates the full
208 // DNSSEC chain and reflects the result in the AD (Authenticated Data) bit
209 // of the response flags. This is more reliable than querying DNSKEY directly,
210 // because resolvers don't always set AD on DNSKEY queries and many zones
211 // don't return DNSKEY records via DoH JSON.
212 let response = try await lookupResponse(
213 domain: domain,
214 queryType: 6, // SOA
215 resolverURLString: resolverURLString,
216 includeDNSSECData: true
217 )
218 return response.authenticatedData
219 }
220
221 private static func currentResolverURLString() -> String {
222 let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey)
223 return DNSResolverOption.resolvedURLString(from: storedValue)
224 }
225
226 private static func validatedResolverURL(from urlString: String) throws -> URL {
227 guard let url = URL(string: urlString) else {
228 throw URLError(.badURL)
229 }
230 return url
231 }
232
233 private static func lookupResponseViaRFC8484(
234 domain: String,
235 queryType: Int,
236 resolverURL: URL,
237 includeDNSSECData: Bool
238 ) async throws -> DNSLookupResponse {
239 let queryData = try buildDNSQueryMessage(
240 domain: domain,
241 queryType: queryType,
242 dnssecOK: includeDNSSECData
243 )
244 let encodedQuery = base64URLEncodedString(for: queryData)
245
246 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
247 components.queryItems = [URLQueryItem(name: "dns", value: encodedQuery)]
248
249 var request = URLRequest(url: components.url!)
250 request.setValue("application/dns-message", forHTTPHeaderField: "Accept")
251
252 let (data, response) = try await URLSession.shared.data(for: request)
253
254 guard let httpResponse = response as? HTTPURLResponse,
255 httpResponse.statusCode == 200 else {
256 throw URLError(.badServerResponse)
257 }
258
259 return try parseDNSMessage(data)
260 }
261
262 private static func buildDNSQueryMessage(domain: String, queryType: Int, dnssecOK: Bool = false) throws -> Data {
263 let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines)
264 let labels = normalizedName.split(separator: ".")
265
266 var data = Data()
267 data.appendUInt16(UInt16.random(in: UInt16.min ... UInt16.max))
268 data.appendUInt16(0x0100)
269 data.appendUInt16(1)
270 data.appendUInt16(0)
271 data.appendUInt16(0)
272 data.appendUInt16(dnssecOK ? 1 : 0)
273
274 for label in labels {
275 guard let labelData = label.data(using: .utf8),
276 labelData.count <= 63 else {
277 throw URLError(.badURL)
278 }
279 data.append(UInt8(labelData.count))
280 data.append(labelData)
281 }
282
283 data.append(0)
284 data.appendUInt16(UInt16(queryType))
285 data.appendUInt16(UInt16(internetClass))
286
287 if dnssecOK {
288 data.appendUInt16(0)
289 data.appendUInt16(1)
290 data.appendUInt16(0)
291 data.appendUInt16(0)
292 data.appendUInt16(11)
293 data.appendUInt16(10)
294 data.appendUInt16(8_192)
295 data.appendUInt16(32_768)
296 data.appendUInt16(0)
297 }
298
299 return data
300 }
301
302 private static func base64URLEncodedString(for data: Data) -> String {
303 data.base64EncodedString()
304 .replacingOccurrences(of: "+", with: "-")
305 .replacingOccurrences(of: "/", with: "_")
306 .replacingOccurrences(of: "=", with: "")
307 }
308
309 private static func parseDNSMessage(_ data: Data) throws -> DNSLookupResponse {
310 guard data.count >= 12 else {
311 throw URLError(.cannotParseResponse)
312 }
313
314 let flags = readUInt16(in: data, at: 2)
315 let answerCount = Int(readUInt16(in: data, at: 6))
316 let questionCount = Int(readUInt16(in: data, at: 4))
317 var offset = 12
318
319 for _ in 0 ..< questionCount {
320 _ = try readDomainName(in: data, offset: &offset)
321 offset += 4
322 }
323
324 var answers: [CloudflareDNSResponse.CloudflareDNSAnswer] = []
325 for _ in 0 ..< answerCount {
326 let name = try readDomainName(in: data, offset: &offset)
327 let type = Int(readUInt16(in: data, at: offset))
328 offset += 2
329 _ = readUInt16(in: data, at: offset)
330 offset += 2
331 let ttl = Int(readUInt32(in: data, at: offset))
332 offset += 4
333 let dataLength = Int(readUInt16(in: data, at: offset))
334 offset += 2
335
336 guard offset + dataLength <= data.count else {
337 throw URLError(.cannotParseResponse)
338 }
339
340 let recordDataOffset = offset
341 let recordData = data.subdata(in: recordDataOffset ..< (recordDataOffset + dataLength))
342 offset += dataLength
343
344 let parsedValue = try parseRecordData(
345 from: data,
346 recordType: type,
347 recordDataOffset: recordDataOffset,
348 recordData: recordData
349 )
350
351 answers.append(.init(
352 name: name,
353 type: type,
354 TTL: ttl,
355 data: parsedValue
356 ))
357 }
358
359 return DNSLookupResponse(
360 answers: answers,
361 authenticatedData: (flags & 0x0020) != 0
362 )
363 }
364
365 private static func parseRecordData(
366 from message: Data,
367 recordType: Int,
368 recordDataOffset: Int,
369 recordData: Data
370 ) throws -> String {
371 switch recordType {
372 case 1:
373 guard recordData.count == 4 else { throw URLError(.cannotParseResponse) }
374 return recordData.map(String.init).joined(separator: ".")
375 case 2, 5:
376 var offset = recordDataOffset
377 return try readDomainName(in: message, offset: &offset)
378 case 15:
379 guard recordData.count >= 3 else { throw URLError(.cannotParseResponse) }
380 let preference = readUInt16(in: recordData, at: 0)
381 var exchangeOffset = recordDataOffset + 2
382 let exchange = try readDomainName(in: message, offset: &exchangeOffset)
383 return "\(preference) \(exchange)"
384 case 16:
385 return try parseTXTData(recordData)
386 case 28:
387 guard recordData.count == 16 else { throw URLError(.cannotParseResponse) }
388 return stride(from: 0, to: 16, by: 2)
389 .map { index in
390 String(format: "%x", readUInt16(in: recordData, at: index))
391 }
392 .joined(separator: ":")
393 case 6:
394 var offset = recordDataOffset
395 let mname = try readDomainName(in: message, offset: &offset)
396 let rname = try readDomainName(in: message, offset: &offset)
397 let serial = readUInt32(in: message, at: offset)
398 let refresh = readUInt32(in: message, at: offset + 4)
399 let retry = readUInt32(in: message, at: offset + 8)
400 let expire = readUInt32(in: message, at: offset + 12)
401 let minimum = readUInt32(in: message, at: offset + 16)
402 return "\(mname) \(rname) \(serial) \(refresh) \(retry) \(expire) \(minimum)"
403 case 33:
404 guard recordData.count >= 7 else { throw URLError(.cannotParseResponse) }
405 let priority = readUInt16(in: recordData, at: 0)
406 let weight = readUInt16(in: recordData, at: 2)
407 let port = readUInt16(in: recordData, at: 4)
408 var targetOffset = recordDataOffset + 6
409 let target = try readDomainName(in: message, offset: &targetOffset)
410 return "\(priority) \(weight) \(port) \(target)"
411 case 43:
412 guard recordData.count >= 4 else { throw URLError(.cannotParseResponse) }
413 let keyTag = readUInt16(in: recordData, at: 0)
414 let algorithm = recordData[2]
415 let digestType = recordData[3]
416 let digest = recordData.dropFirst(4).map { String(format: "%02X", $0) }.joined()
417 return "\(keyTag) \(algorithm) \(digestType) \(digest)"
418 case 46:
419 return "RRSIG"
420 case 257:
421 guard recordData.count >= 2 else { throw URLError(.cannotParseResponse) }
422 let flags = recordData[0]
423 let tagLength = Int(recordData[1])
424 guard recordData.count >= 2 + tagLength else {
425 throw URLError(.cannotParseResponse)
426 }
427 let tagData = recordData.subdata(in: 2 ..< (2 + tagLength))
428 let valueData = recordData.dropFirst(2 + tagLength)
429 let tag = String(decoding: tagData, as: UTF8.self)
430 let value = String(decoding: valueData, as: UTF8.self)
431 return "\(flags) \(tag) \"\(value)\""
432 default:
433 return recordData.base64EncodedString()
434 }
435 }
436
437 private static func parseTXTData(_ data: Data) throws -> String {
438 var offset = 0
439 var strings: [String] = []
440
441 while offset < data.count {
442 let count = Int(data[offset])
443 offset += 1
444 guard offset + count <= data.count else {
445 throw URLError(.cannotParseResponse)
446 }
447 let stringData = data.subdata(in: offset ..< (offset + count))
448 strings.append(String(decoding: stringData, as: UTF8.self))
449 offset += count
450 }
451
452 return strings.joined()
453 }
454
455 private static func readDomainName(in data: Data, offset: inout Int) throws -> String {
456 var labels: [String] = []
457 var currentOffset = offset
458 var jumped = false
459 var seenOffsets = Set<Int>()
460
461 while true {
462 guard currentOffset < data.count else {
463 throw URLError(.cannotParseResponse)
464 }
465
466 let length = Int(data[currentOffset])
467
468 if length == 0 {
469 if !jumped {
470 offset = currentOffset + 1
471 }
472 break
473 }
474
475 if length & 0xC0 == 0xC0 {
476 guard currentOffset + 1 < data.count else {
477 throw URLError(.cannotParseResponse)
478 }
479
480 let pointer = ((length & 0x3F) << 8) | Int(data[currentOffset + 1])
481 guard seenOffsets.insert(pointer).inserted else {
482 throw URLError(.cannotParseResponse)
483 }
484
485 if !jumped {
486 offset = currentOffset + 2
487 }
488 currentOffset = pointer
489 jumped = true
490 continue
491 }
492
493 let labelStart = currentOffset + 1
494 let labelEnd = labelStart + length
495 guard labelEnd <= data.count else {
496 throw URLError(.cannotParseResponse)
497 }
498
499 let labelData = data.subdata(in: labelStart ..< labelEnd)
500 labels.append(String(decoding: labelData, as: UTF8.self))
501 currentOffset = labelEnd
502 }
503
504 return labels.joined(separator: ".")
505 }
506
507 private static func readUInt16(in data: Data, at offset: Int) -> UInt16 {
508 let upper = UInt16(data[offset]) << 8
509 let lower = UInt16(data[offset + 1])
510 return upper | lower
511 }
512
513 private static func readUInt32(in data: Data, at offset: Int) -> UInt32 {
514 let first = UInt32(data[offset]) << 24
515 let second = UInt32(data[offset + 1]) << 16
516 let third = UInt32(data[offset + 2]) << 8
517 let fourth = UInt32(data[offset + 3])
518 return first | second | third | fourth
519 }
520}
521
522private struct DNSLookupResponse {
523 let answers: [CloudflareDNSResponse.CloudflareDNSAnswer]
524 let authenticatedData: Bool
525}
526
527private extension Data {
528 mutating func appendUInt16(_ value: UInt16) {
529 append(UInt8((value >> 8) & 0xFF))
530 append(UInt8(value & 0xFF))
531 }
532}