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