Commit 9c691f6c6c

9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41

parent: ede15c586a

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-03 21:51 UTC

DomainDig 1.3.0

- Add SOA, SRV, CAA, and DS DNS record lookups
- Add DNSSEC status detection via RRSIG queries
- Add configurable DNS-over-HTTPS resolver settings
- Support Cloudflare, Google, Quad9, and custom HTTPS resolvers
- Improve DoH compatibility with standards-based fallback handling
- Keep DNS results UI compact with a single DNSSEC indicator

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +4 −4
@@ -265,7 +265,7 @@
265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
267 CODE_SIGN_STYLE = Automatic; 267 CODE_SIGN_STYLE = Automatic;
268 CURRENT_PROJECT_VERSION = 4; 268 CURRENT_PROJECT_VERSION = 5;
269 DEVELOPMENT_TEAM = ZCNAX3VL9D; 269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
270 ENABLE_PREVIEWS = YES; 270 ENABLE_PREVIEWS = YES;
271 GENERATE_INFOPLIST_FILE = YES; 271 GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
282 "$(inherited)", 282 "$(inherited)",
283 "@executable_path/Frameworks", 283 "@executable_path/Frameworks",
284 ); 284 );
285 MARKETING_VERSION = 1.2.1; 285 MARKETING_VERSION = 1.3.0;
286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
287 PRODUCT_NAME = "$(TARGET_NAME)"; 287 PRODUCT_NAME = "$(TARGET_NAME)";
288 STRING_CATALOG_GENERATE_SYMBOLS = YES; 288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
303 CODE_SIGN_STYLE = Automatic; 303 CODE_SIGN_STYLE = Automatic;
304 CURRENT_PROJECT_VERSION = 4; 304 CURRENT_PROJECT_VERSION = 5;
305 DEVELOPMENT_TEAM = ZCNAX3VL9D; 305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
306 ENABLE_PREVIEWS = YES; 306 ENABLE_PREVIEWS = YES;
307 GENERATE_INFOPLIST_FILE = YES; 307 GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
318 "$(inherited)", 318 "$(inherited)",
319 "@executable_path/Frameworks", 319 "@executable_path/Frameworks",
320 ); 320 );
321 MARKETING_VERSION = 1.2.1; 321 MARKETING_VERSION = 1.3.0;
322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
323 PRODUCT_NAME = "$(TARGET_NAME)"; 323 PRODUCT_NAME = "$(TARGET_NAME)";
324 STRING_CATALOG_GENERATE_SYMBOLS = YES; 324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +86 −1
@@ -54,6 +54,14 @@ struct ContentView: View {
54 .foregroundStyle(.secondary) 54 .foregroundStyle(.secondary)
55 } 55 }
56 } 56 }
57 ToolbarItem(placement: .topBarTrailing) {
58 NavigationLink {
59 SettingsView()
60 } label: {
61 Image(systemName: "gearshape")
62 .foregroundStyle(.secondary)
63 }
64 }
57 } 65 }
58 } 66 }
59 .onAppear { 67 .onAppear {
@@ -257,7 +265,16 @@ struct ContentView: View {
257 265
258 private var dnsResultsSection: some View { 266 private var dnsResultsSection: some View {
259 VStack(alignment: .leading, spacing: 12) { 267 VStack(alignment: .leading, spacing: 12) {
260 sectionHeader("DNS Records") 268 HStack(alignment: .top, spacing: 8) {
269 sectionHeader("DNS Records")
270 Spacer()
271 if let dnssecSigned = dnssecStatus {
272 Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗")
273 .font(.system(.caption2, design: .monospaced))
274 .foregroundStyle(dnssecSigned ? .green : .red)
275 .padding(.top, 1)
276 }
277 }
261 278
262 if viewModel.dnsLoading { 279 if viewModel.dnsLoading {
263 ProgressView("Querying DNS…") 280 ProgressView("Querying DNS…")
@@ -596,6 +613,10 @@ struct ContentView: View {
596 .foregroundStyle(.white) 613 .foregroundStyle(.white)
597 } 614 }
598 615
616 private var dnssecStatus: Bool? {
617 viewModel.dnsSections.compactMap(\.dnssecSigned).first
618 }
619
599 private func certRow(_ label: String, _ value: String) -> some View { 620 private func certRow(_ label: String, _ value: String) -> some View {
600 VStack(alignment: .leading, spacing: 2) { 621 VStack(alignment: .leading, spacing: 2) {
601 Text(label) 622 Text(label)
@@ -681,6 +702,70 @@ extension DateFormatter {
681 }() 702 }()
682} 703}
683 704
705private struct SettingsView: View {
706 @AppStorage(DNSResolverOption.userDefaultsKey)
707 private var storedResolverURL = DNSResolverOption.defaultURLString
708
709 @State private var resolverOption: DNSResolverOption = .cloudflare
710 @State private var customResolverURL = DNSResolverOption.defaultURLString
711
712 private var customResolverError: String? {
713 guard resolverOption == .custom else {
714 return nil
715 }
716
717 return DNSResolverOption.isValidCustomURL(customResolverURL)
718 ? nil
719 : "Resolver URL must start with https://"
720 }
721
722 var body: some View {
723 Form {
724 Section {
725 Picker("Resolver", selection: $resolverOption) {
726 ForEach(DNSResolverOption.allCases) { option in
727 Text(option.title).tag(option)
728 }
729 }
730
731 if resolverOption == .custom {
732 TextField("https://resolver.example/dns-query", text: $customResolverURL)
733 .textInputAutocapitalization(.never)
734 .autocorrectionDisabled()
735 .keyboardType(.URL)
736
737 if let customResolverError {
738 Text(customResolverError)
739 .font(.caption)
740 .foregroundStyle(.red)
741 }
742 }
743 }
744 }
745 .navigationTitle("Settings")
746 .onAppear {
747 let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
748 resolverOption = DNSResolverOption.option(for: currentResolverURL)
749 customResolverURL = resolverOption == .custom
750 ? currentResolverURL
751 : DNSResolverOption.defaultURLString
752 }
753 .onChange(of: resolverOption) { _, newValue in
754 guard let presetURL = newValue.urlString else {
755 storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
756 return
757 }
758 storedResolverURL = presetURL
759 }
760 .onChange(of: customResolverURL) { _, newValue in
761 guard resolverOption == .custom else {
762 return
763 }
764 storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
765 }
766 }
767}
768
684#Preview { 769#Preview {
685 ContentView() 770 ContentView()
686} 771}
DomainDig/DNSLookupService.swift +432 −30
@@ -1,46 +1,108 @@
1import Foundation 1import Foundation
2 2
3struct DNSLookupService { 3enum DNSResolverOption: String, CaseIterable, Identifiable {
4 private static let baseURL = "https://cloudflare-dns.com/dns-query" 4 case cloudflare
5 case google
6 case quad9
7 case custom
5 8
6 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { 9 static let userDefaultsKey = "dnsResolverURL"
7 var components = URLComponents(string: baseURL)! 10 static let defaultURLString = "https://cloudflare-dns.com/dns-query"
8 components.queryItems = [
9 URLQueryItem(name: "name", value: domain),
10 URLQueryItem(name: "type", value: String(recordType.queryType))
11 ]
12 11
13 var request = URLRequest(url: components.url!) 12 var id: String { rawValue }
14 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
15 13
16 let (data, response) = try await URLSession.shared.data(for: request) 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 }
17 22
18 guard let httpResponse = response as? HTTPURLResponse, 23 var urlString: String? {
19 httpResponse.statusCode == 200 else { 24 switch self {
20 throw URLError(.badServerResponse) 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
21 } 29 }
30 }
22 31
23 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data) 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 }
24 36
25 guard let answers = dnsResponse.Answer else { 37 static func isValidCustomURL(_ urlString: String) -> Bool {
26 return [] 38 let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
39 guard trimmedURL.hasPrefix("https://") else {
40 return false
27 } 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 internetClass = 1
62
63 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
64 try await lookup(
65 domain: domain,
66 recordType: recordType,
67 resolverURLString: currentResolverURLString()
68 )
69 }
70
71 static func lookup(
72 domain: String,
73 recordType: DNSRecordType,
74 resolverURLString: String
75 ) async throws -> [DNSRecord] {
76 let answers = try await lookupAnswers(
77 domain: domain,
78 queryType: recordType.queryType,
79 resolverURLString: resolverURLString
80 )
28 81
29 // Filter answers to only include the requested type
30 return answers 82 return answers
31 .filter { $0.type == recordType.queryType } 83 .filter { $0.type == recordType.queryType }
32 .map { answer in 84 .map { answer in
33 let value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) 85 let value: String
86 if recordType.usesRawDataValue {
87 value = answer.data
88 } else {
89 value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
90 }
34 return DNSRecord(value: value, ttl: answer.TTL) 91 return DNSRecord(value: value, ttl: answer.TTL)
35 } 92 }
36 } 93 }
37 94
38 static func lookupAll(domain: String) async -> [DNSSection] { 95 static func lookupAll(domain: String) async -> [DNSSection] {
39 // Each task returns (recordType, apex records, wildcard records). 96 typealias Result = (
40 typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?) 97 type: DNSRecordType,
98 records: [DNSRecord],
99 wildcard: [DNSRecord],
100 dnssecSigned: Bool?,
101 error: String?
102 )
41 103
42 // Record types that support wildcard queries 104 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA]
43 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT] 105 let resolverURLString = currentResolverURLString()
44 106
45 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in 107 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
46 for recordType in DNSRecordType.allCases { 108 for recordType in DNSRecordType.allCases {
@@ -48,25 +110,36 @@ struct DNSLookupService {
48 group.addTask { 110 group.addTask {
49 var apexRecords: [DNSRecord] = [] 111 var apexRecords: [DNSRecord] = []
50 var wildcardRecords: [DNSRecord] = [] 112 var wildcardRecords: [DNSRecord] = []
113 var dnssecSigned: Bool?
51 var lookupError: String? 114 var lookupError: String?
52 115
53 // Apex query
54 do { 116 do {
55 apexRecords = try await lookup(domain: domain, recordType: recordType) 117 apexRecords = try await lookup(
118 domain: domain,
119 recordType: recordType,
120 resolverURLString: resolverURLString
121 )
122 dnssecSigned = try await lookupDNSSECStatus(
123 domain: domain,
124 resolverURLString: resolverURLString
125 )
56 } catch { 126 } catch {
57 lookupError = error.localizedDescription 127 lookupError = error.localizedDescription
58 } 128 }
59 129
60 // Wildcard query (only for applicable types, and only if apex didn't fail)
61 if shouldQueryWildcard && lookupError == nil { 130 if shouldQueryWildcard && lookupError == nil {
62 do { 131 do {
63 wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType) 132 wildcardRecords = try await lookup(
133 domain: "*.\(domain)",
134 recordType: recordType,
135 resolverURLString: resolverURLString
136 )
64 } catch { 137 } catch {
65 // Wildcard failure is non-fatal; just leave empty 138 // Wildcard failure is non-fatal; just leave empty.
66 } 139 }
67 } 140 }
68 141
69 return (recordType, apexRecords, wildcardRecords, lookupError) 142 return (recordType, apexRecords, wildcardRecords, dnssecSigned, lookupError)
70 } 143 }
71 } 144 }
72 145
@@ -76,15 +149,344 @@ struct DNSLookupService {
76 recordType: result.type, 149 recordType: result.type,
77 records: result.records, 150 records: result.records,
78 wildcardRecords: result.wildcard, 151 wildcardRecords: result.wildcard,
152 dnssecSigned: result.dnssecSigned,
79 error: result.error 153 error: result.error
80 )) 154 ))
81 } 155 }
82 156
83 // Sort to maintain consistent order
84 let order = DNSRecordType.allCases 157 let order = DNSRecordType.allCases
85 return sections.sorted { a, b in 158 return sections.sorted { a, b in
86 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0) 159 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
87 } 160 }
88 } 161 }
89 } 162 }
163
164 private static func lookupAnswers(
165 domain: String,
166 queryType: Int,
167 resolverURLString: String
168 ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
169 let resolverURL = try validatedResolverURL(from: resolverURLString)
170 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
171 components.queryItems = [
172 URLQueryItem(name: "name", value: domain),
173 URLQueryItem(name: "type", value: String(queryType))
174 ]
175
176 var request = URLRequest(url: components.url!)
177 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
178
179 let (data, response) = try await URLSession.shared.data(for: request)
180
181 guard let httpResponse = response as? HTTPURLResponse,
182 httpResponse.statusCode == 200 else {
183 return try await lookupAnswersViaRFC8484(
184 domain: domain,
185 queryType: queryType,
186 resolverURL: resolverURL
187 )
188 }
189
190 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
191
192 return dnsResponse.Answer ?? []
193 }
194
195 private static func lookupDNSSECStatus(
196 domain: String,
197 resolverURLString: String
198 ) async throws -> Bool {
199 let answers = try await lookupAnswers(
200 domain: domain,
201 queryType: rrsigQueryType,
202 resolverURLString: resolverURLString
203 )
204 return answers.contains(where: { $0.type == rrsigQueryType })
205 }
206
207 private static func currentResolverURLString() -> String {
208 let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey)
209 return DNSResolverOption.resolvedURLString(from: storedValue)
210 }
211
212 private static func validatedResolverURL(from urlString: String) throws -> URL {
213 guard let url = URL(string: urlString) else {
214 throw URLError(.badURL)
215 }
216 return url
217 }
218
219 private static func lookupAnswersViaRFC8484(
220 domain: String,
221 queryType: Int,
222 resolverURL: URL
223 ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
224 let queryData = try buildDNSQueryMessage(domain: domain, queryType: queryType)
225 let encodedQuery = base64URLEncodedString(for: queryData)
226
227 var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
228 components.queryItems = [URLQueryItem(name: "dns", value: encodedQuery)]
229
230 var request = URLRequest(url: components.url!)
231 request.setValue("application/dns-message", forHTTPHeaderField: "Accept")
232
233 let (data, response) = try await URLSession.shared.data(for: request)
234
235 guard let httpResponse = response as? HTTPURLResponse,
236 httpResponse.statusCode == 200 else {
237 throw URLError(.badServerResponse)
238 }
239
240 return try parseDNSMessage(data)
241 }
242
243 private static func buildDNSQueryMessage(domain: String, queryType: Int) throws -> Data {
244 let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines)
245 let labels = normalizedName.split(separator: ".")
246
247 var data = Data()
248 data.appendUInt16(UInt16.random(in: UInt16.min ... UInt16.max))
249 data.appendUInt16(0x0100)
250 data.appendUInt16(1)
251 data.appendUInt16(0)
252 data.appendUInt16(0)
253 data.appendUInt16(0)
254
255 for label in labels {
256 guard let labelData = label.data(using: .utf8),
257 labelData.count <= 63 else {
258 throw URLError(.badURL)
259 }
260 data.append(UInt8(labelData.count))
261 data.append(labelData)
262 }
263
264 data.append(0)
265 data.appendUInt16(UInt16(queryType))
266 data.appendUInt16(UInt16(internetClass))
267
268 return data
269 }
270
271 private static func base64URLEncodedString(for data: Data) -> String {
272 data.base64EncodedString()
273 .replacingOccurrences(of: "+", with: "-")
274 .replacingOccurrences(of: "/", with: "_")
275 .replacingOccurrences(of: "=", with: "")
276 }
277
278 private static func parseDNSMessage(_ data: Data) throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
279 guard data.count >= 12 else {
280 throw URLError(.cannotParseResponse)
281 }
282
283 let answerCount = Int(readUInt16(in: data, at: 6))
284 let questionCount = Int(readUInt16(in: data, at: 4))
285 var offset = 12
286
287 for _ in 0 ..< questionCount {
288 _ = try readDomainName(in: data, offset: &offset)
289 offset += 4
290 }
291
292 var answers: [CloudflareDNSResponse.CloudflareDNSAnswer] = []
293 for _ in 0 ..< answerCount {
294 let name = try readDomainName(in: data, offset: &offset)
295 let type = Int(readUInt16(in: data, at: offset))
296 offset += 2
297 _ = readUInt16(in: data, at: offset)
298 offset += 2
299 let ttl = Int(readUInt32(in: data, at: offset))
300 offset += 4
301 let dataLength = Int(readUInt16(in: data, at: offset))
302 offset += 2
303
304 guard offset + dataLength <= data.count else {
305 throw URLError(.cannotParseResponse)
306 }
307
308 let recordDataOffset = offset
309 let recordData = data.subdata(in: recordDataOffset ..< (recordDataOffset + dataLength))
310 offset += dataLength
311
312 let parsedValue = try parseRecordData(
313 from: data,
314 recordType: type,
315 recordDataOffset: recordDataOffset,
316 recordData: recordData
317 )
318
319 answers.append(.init(
320 name: name,
321 type: type,
322 TTL: ttl,
323 data: parsedValue
324 ))
325 }
326
327 return answers
328 }
329
330 private static func parseRecordData(
331 from message: Data,
332 recordType: Int,
333 recordDataOffset: Int,
334 recordData: Data
335 ) throws -> String {
336 switch recordType {
337 case 1:
338 guard recordData.count == 4 else { throw URLError(.cannotParseResponse) }
339 return recordData.map(String.init).joined(separator: ".")
340 case 2, 5:
341 var offset = recordDataOffset
342 return try readDomainName(in: message, offset: &offset)
343 case 15:
344 guard recordData.count >= 3 else { throw URLError(.cannotParseResponse) }
345 let preference = readUInt16(in: recordData, at: 0)
346 var exchangeOffset = recordDataOffset + 2
347 let exchange = try readDomainName(in: message, offset: &exchangeOffset)
348 return "\(preference) \(exchange)"
349 case 16:
350 return try parseTXTData(recordData)
351 case 28:
352 guard recordData.count == 16 else { throw URLError(.cannotParseResponse) }
353 return stride(from: 0, to: 16, by: 2)
354 .map { index in
355 String(format: "%x", readUInt16(in: recordData, at: index))
356 }
357 .joined(separator: ":")
358 case 6:
359 var offset = recordDataOffset
360 let mname = try readDomainName(in: message, offset: &offset)
361 let rname = try readDomainName(in: message, offset: &offset)
362 let serial = readUInt32(in: message, at: offset)
363 let refresh = readUInt32(in: message, at: offset + 4)
364 let retry = readUInt32(in: message, at: offset + 8)
365 let expire = readUInt32(in: message, at: offset + 12)
366 let minimum = readUInt32(in: message, at: offset + 16)
367 return "\(mname) \(rname) \(serial) \(refresh) \(retry) \(expire) \(minimum)"
368 case 33:
369 guard recordData.count >= 7 else { throw URLError(.cannotParseResponse) }
370 let priority = readUInt16(in: recordData, at: 0)
371 let weight = readUInt16(in: recordData, at: 2)
372 let port = readUInt16(in: recordData, at: 4)
373 var targetOffset = recordDataOffset + 6
374 let target = try readDomainName(in: message, offset: &targetOffset)
375 return "\(priority) \(weight) \(port) \(target)"
376 case 43:
377 guard recordData.count >= 4 else { throw URLError(.cannotParseResponse) }
378 let keyTag = readUInt16(in: recordData, at: 0)
379 let algorithm = recordData[2]
380 let digestType = recordData[3]
381 let digest = recordData.dropFirst(4).map { String(format: "%02X", $0) }.joined()
382 return "\(keyTag) \(algorithm) \(digestType) \(digest)"
383 case 46:
384 return "RRSIG"
385 case 257:
386 guard recordData.count >= 2 else { throw URLError(.cannotParseResponse) }
387 let flags = recordData[0]
388 let tagLength = Int(recordData[1])
389 guard recordData.count >= 2 + tagLength else {
390 throw URLError(.cannotParseResponse)
391 }
392 let tagData = recordData.subdata(in: 2 ..< (2 + tagLength))
393 let valueData = recordData.dropFirst(2 + tagLength)
394 let tag = String(decoding: tagData, as: UTF8.self)
395 let value = String(decoding: valueData, as: UTF8.self)
396 return "\(flags) \(tag) \"\(value)\""
397 default:
398 return recordData.base64EncodedString()
399 }
400 }
401
402 private static func parseTXTData(_ data: Data) throws -> String {
403 var offset = 0
404 var strings: [String] = []
405
406 while offset < data.count {
407 let count = Int(data[offset])
408 offset += 1
409 guard offset + count <= data.count else {
410 throw URLError(.cannotParseResponse)
411 }
412 let stringData = data.subdata(in: offset ..< (offset + count))
413 strings.append(String(decoding: stringData, as: UTF8.self))
414 offset += count
415 }
416
417 return strings.joined()
418 }
419
420 private static func readDomainName(in data: Data, offset: inout Int) throws -> String {
421 var labels: [String] = []
422 var currentOffset = offset
423 var jumped = false
424 var seenOffsets = Set<Int>()
425
426 while true {
427 guard currentOffset < data.count else {
428 throw URLError(.cannotParseResponse)
429 }
430
431 let length = Int(data[currentOffset])
432
433 if length == 0 {
434 if !jumped {
435 offset = currentOffset + 1
436 }
437 break
438 }
439
440 if length & 0xC0 == 0xC0 {
441 guard currentOffset + 1 < data.count else {
442 throw URLError(.cannotParseResponse)
443 }
444
445 let pointer = ((length & 0x3F) << 8) | Int(data[currentOffset + 1])
446 guard seenOffsets.insert(pointer).inserted else {
447 throw URLError(.cannotParseResponse)
448 }
449
450 if !jumped {
451 offset = currentOffset + 2
452 }
453 currentOffset = pointer
454 jumped = true
455 continue
456 }
457
458 let labelStart = currentOffset + 1
459 let labelEnd = labelStart + length
460 guard labelEnd <= data.count else {
461 throw URLError(.cannotParseResponse)
462 }
463
464 let labelData = data.subdata(in: labelStart ..< labelEnd)
465 labels.append(String(decoding: labelData, as: UTF8.self))
466 currentOffset = labelEnd
467 }
468
469 return labels.joined(separator: ".")
470 }
471
472 private static func readUInt16(in data: Data, at offset: Int) -> UInt16 {
473 let upper = UInt16(data[offset]) << 8
474 let lower = UInt16(data[offset + 1])
475 return upper | lower
476 }
477
478 private static func readUInt32(in data: Data, at offset: Int) -> UInt32 {
479 let first = UInt32(data[offset]) << 24
480 let second = UInt32(data[offset + 1]) << 16
481 let third = UInt32(data[offset + 2]) << 8
482 let fourth = UInt32(data[offset + 3])
483 return first | second | third | fourth
484 }
485}
486
487private extension Data {
488 mutating func appendUInt16(_ value: UInt16) {
489 append(UInt8((value >> 8) & 0xFF))
490 append(UInt8(value & 0xFF))
491 }
90} 492}
DomainDig/Models.swift +18
@@ -9,6 +9,10 @@ enum DNSRecordType: String, CaseIterable, Codable {
9 case NS 9 case NS
10 case TXT 10 case TXT
11 case CNAME 11 case CNAME
12 case SOA
13 case SRV
14 case CAA
15 case DS
12 16
13 var queryType: Int { 17 var queryType: Int {
14 switch self { 18 switch self {
@@ -18,6 +22,19 @@ enum DNSRecordType: String, CaseIterable, Codable {
18 case .NS: return 2 22 case .NS: return 2
19 case .TXT: return 16 23 case .TXT: return 16
20 case .CNAME: return 5 24 case .CNAME: return 5
25 case .SOA: return 6
26 case .SRV: return 33
27 case .CAA: return 257
28 case .DS: return 43
29 }
30 }
31
32 var usesRawDataValue: Bool {
33 switch self {
34 case .TXT, .SOA, .DS:
35 return true
36 default:
37 return false
21 } 38 }
22 } 39 }
23} 40}
@@ -33,6 +50,7 @@ struct DNSSection: Identifiable, Codable {
33 let recordType: DNSRecordType 50 let recordType: DNSRecordType
34 var records: [DNSRecord] 51 var records: [DNSRecord]
35 var wildcardRecords: [DNSRecord] = [] 52 var wildcardRecords: [DNSRecord] = []
53 var dnssecSigned: Bool?
36 var error: String? 54 var error: String?
37} 55}
38 56