krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.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 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}