krz/domain-dig

an ios app for DNS & SSL analysis

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

9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-03T21:51:18Z

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
 DomainDig.xcodeproj/project.pbxproj |   8 +-
 DomainDig/ContentView.swift         |  87 ++++++-
 DomainDig/DNSLookupService.swift    | 462 +++++++++++++++++++++++++++++++++---
 DomainDig/Models.swift              |  18 ++
 4 files changed, 540 insertions(+), 35 deletions(-)

diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 12f8baf..4e7cbc6 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -265,7 +265,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 4;
+				CURRENT_PROJECT_VERSION = 5;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -282,7 +282,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.2.1;
+				MARKETING_VERSION = 1.3.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -301,7 +301,7 @@
 				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
 				ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
 				CODE_SIGN_STYLE = Automatic;
-				CURRENT_PROJECT_VERSION = 4;
+				CURRENT_PROJECT_VERSION = 5;
 				DEVELOPMENT_TEAM = ZCNAX3VL9D;
 				ENABLE_PREVIEWS = YES;
 				GENERATE_INFOPLIST_FILE = YES;
@@ -318,7 +318,7 @@
 					"$(inherited)",
 					"@executable_path/Frameworks",
 				);
-				MARKETING_VERSION = 1.2.1;
+				MARKETING_VERSION = 1.3.0;
 				PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
 				PRODUCT_NAME = "$(TARGET_NAME)";
 				STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index b6a7607..87d8a85 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -54,6 +54,14 @@ struct ContentView: View {
                             .foregroundStyle(.secondary)
                     }
                 }
+                ToolbarItem(placement: .topBarTrailing) {
+                    NavigationLink {
+                        SettingsView()
+                    } label: {
+                        Image(systemName: "gearshape")
+                            .foregroundStyle(.secondary)
+                    }
+                }
             }
         }
         .onAppear {
@@ -257,7 +265,16 @@ struct ContentView: View {
 
     private var dnsResultsSection: some View {
         VStack(alignment: .leading, spacing: 12) {
-            sectionHeader("DNS Records")
+            HStack(alignment: .top, spacing: 8) {
+                sectionHeader("DNS Records")
+                Spacer()
+                if let dnssecSigned = dnssecStatus {
+                    Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗")
+                        .font(.system(.caption2, design: .monospaced))
+                        .foregroundStyle(dnssecSigned ? .green : .red)
+                        .padding(.top, 1)
+                }
+            }
 
             if viewModel.dnsLoading {
                 ProgressView("Querying DNS…")
@@ -596,6 +613,10 @@ struct ContentView: View {
             .foregroundStyle(.white)
     }
 
+    private var dnssecStatus: Bool? {
+        viewModel.dnsSections.compactMap(\.dnssecSigned).first
+    }
+
     private func certRow(_ label: String, _ value: String) -> some View {
         VStack(alignment: .leading, spacing: 2) {
             Text(label)
@@ -681,6 +702,70 @@ extension DateFormatter {
     }()
 }
 
+private struct SettingsView: View {
+    @AppStorage(DNSResolverOption.userDefaultsKey)
+    private var storedResolverURL = DNSResolverOption.defaultURLString
+
+    @State private var resolverOption: DNSResolverOption = .cloudflare
+    @State private var customResolverURL = DNSResolverOption.defaultURLString
+
+    private var customResolverError: String? {
+        guard resolverOption == .custom else {
+            return nil
+        }
+
+        return DNSResolverOption.isValidCustomURL(customResolverURL)
+            ? nil
+            : "Resolver URL must start with https://"
+    }
+
+    var body: some View {
+        Form {
+            Section {
+                Picker("Resolver", selection: $resolverOption) {
+                    ForEach(DNSResolverOption.allCases) { option in
+                        Text(option.title).tag(option)
+                    }
+                }
+
+                if resolverOption == .custom {
+                    TextField("https://resolver.example/dns-query", text: $customResolverURL)
+                        .textInputAutocapitalization(.never)
+                        .autocorrectionDisabled()
+                        .keyboardType(.URL)
+
+                    if let customResolverError {
+                        Text(customResolverError)
+                            .font(.caption)
+                            .foregroundStyle(.red)
+                    }
+                }
+            }
+        }
+        .navigationTitle("Settings")
+        .onAppear {
+            let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+            resolverOption = DNSResolverOption.option(for: currentResolverURL)
+            customResolverURL = resolverOption == .custom
+                ? currentResolverURL
+                : DNSResolverOption.defaultURLString
+        }
+        .onChange(of: resolverOption) { _, newValue in
+            guard let presetURL = newValue.urlString else {
+                storedResolverURL = customResolverURL.trimmingCharacters(in: .whitespacesAndNewlines)
+                return
+            }
+            storedResolverURL = presetURL
+        }
+        .onChange(of: customResolverURL) { _, newValue in
+            guard resolverOption == .custom else {
+                return
+            }
+            storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        }
+    }
+}
+
 #Preview {
     ContentView()
 }
diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift
index a8f55fd..9bbc97b 100644
--- a/DomainDig/DNSLookupService.swift
+++ b/DomainDig/DNSLookupService.swift
@@ -1,46 +1,108 @@
 import Foundation
 
-struct DNSLookupService {
-    private static let baseURL = "https://cloudflare-dns.com/dns-query"
+enum DNSResolverOption: String, CaseIterable, Identifiable {
+    case cloudflare
+    case google
+    case quad9
+    case custom
 
-    static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
-        var components = URLComponents(string: baseURL)!
-        components.queryItems = [
-            URLQueryItem(name: "name", value: domain),
-            URLQueryItem(name: "type", value: String(recordType.queryType))
-        ]
+    static let userDefaultsKey = "dnsResolverURL"
+    static let defaultURLString = "https://cloudflare-dns.com/dns-query"
 
-        var request = URLRequest(url: components.url!)
-        request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
+    var id: String { rawValue }
 
-        let (data, response) = try await URLSession.shared.data(for: request)
+    var title: String {
+        switch self {
+        case .cloudflare: return "Cloudflare"
+        case .google: return "Google"
+        case .quad9: return "Quad9"
+        case .custom: return "Custom"
+        }
+    }
 
-        guard let httpResponse = response as? HTTPURLResponse,
-              httpResponse.statusCode == 200 else {
-            throw URLError(.badServerResponse)
+    var urlString: String? {
+        switch self {
+        case .cloudflare: return Self.defaultURLString
+        case .google: return "https://dns.google/dns-query"
+        case .quad9: return "https://dns.quad9.net/dns-query"
+        case .custom: return nil
         }
+    }
 
-        let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
+    static func option(for urlString: String) -> DNSResolverOption {
+        let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
+        return Self.allCases.first(where: { $0.urlString == trimmedURL }) ?? .custom
+    }
 
-        guard let answers = dnsResponse.Answer else {
-            return []
+    static func isValidCustomURL(_ urlString: String) -> Bool {
+        let trimmedURL = urlString.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard trimmedURL.hasPrefix("https://") else {
+            return false
         }
+        return URL(string: trimmedURL) != nil
+    }
+
+    static func resolvedURLString(from storedValue: String?) -> String {
+        guard let storedValue else {
+            return defaultURLString
+        }
+
+        let trimmedURL = storedValue.trimmingCharacters(in: .whitespacesAndNewlines)
+        guard !trimmedURL.isEmpty else {
+            return defaultURLString
+        }
+
+        return isValidCustomURL(trimmedURL) ? trimmedURL : defaultURLString
+    }
+}
+
+struct DNSLookupService {
+    private static let rrsigQueryType = 46
+    private static let internetClass = 1
+
+    static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] {
+        try await lookup(
+            domain: domain,
+            recordType: recordType,
+            resolverURLString: currentResolverURLString()
+        )
+    }
+
+    static func lookup(
+        domain: String,
+        recordType: DNSRecordType,
+        resolverURLString: String
+    ) async throws -> [DNSRecord] {
+        let answers = try await lookupAnswers(
+            domain: domain,
+            queryType: recordType.queryType,
+            resolverURLString: resolverURLString
+        )
 
-        // Filter answers to only include the requested type
         return answers
             .filter { $0.type == recordType.queryType }
             .map { answer in
-                let value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+                let value: String
+                if recordType.usesRawDataValue {
+                    value = answer.data
+                } else {
+                    value = answer.data.trimmingCharacters(in: CharacterSet(charactersIn: "\""))
+                }
                 return DNSRecord(value: value, ttl: answer.TTL)
             }
     }
 
     static func lookupAll(domain: String) async -> [DNSSection] {
-        // Each task returns (recordType, apex records, wildcard records).
-        typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?)
+        typealias Result = (
+            type: DNSRecordType,
+            records: [DNSRecord],
+            wildcard: [DNSRecord],
+            dnssecSigned: Bool?,
+            error: String?
+        )
 
-        // Record types that support wildcard queries
-        let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
+        let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT, .SRV, .CAA]
+        let resolverURLString = currentResolverURLString()
 
         return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
             for recordType in DNSRecordType.allCases {
@@ -48,25 +110,36 @@ struct DNSLookupService {
                 group.addTask {
                     var apexRecords: [DNSRecord] = []
                     var wildcardRecords: [DNSRecord] = []
+                    var dnssecSigned: Bool?
                     var lookupError: String?
 
-                    // Apex query
                     do {
-                        apexRecords = try await lookup(domain: domain, recordType: recordType)
+                        apexRecords = try await lookup(
+                            domain: domain,
+                            recordType: recordType,
+                            resolverURLString: resolverURLString
+                        )
+                        dnssecSigned = try await lookupDNSSECStatus(
+                            domain: domain,
+                            resolverURLString: resolverURLString
+                        )
                     } catch {
                         lookupError = error.localizedDescription
                     }
 
-                    // Wildcard query (only for applicable types, and only if apex didn't fail)
                     if shouldQueryWildcard && lookupError == nil {
                         do {
-                            wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType)
+                            wildcardRecords = try await lookup(
+                                domain: "*.\(domain)",
+                                recordType: recordType,
+                                resolverURLString: resolverURLString
+                            )
                         } catch {
-                            // Wildcard failure is non-fatal; just leave empty
+                            // Wildcard failure is non-fatal; just leave empty.
                         }
                     }
 
-                    return (recordType, apexRecords, wildcardRecords, lookupError)
+                    return (recordType, apexRecords, wildcardRecords, dnssecSigned, lookupError)
                 }
             }
 
@@ -76,15 +149,344 @@ struct DNSLookupService {
                     recordType: result.type,
                     records: result.records,
                     wildcardRecords: result.wildcard,
+                    dnssecSigned: result.dnssecSigned,
                     error: result.error
                 ))
             }
 
-            // Sort to maintain consistent order
             let order = DNSRecordType.allCases
             return sections.sorted { a, b in
                 (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0)
             }
         }
     }
+
+    private static func lookupAnswers(
+        domain: String,
+        queryType: Int,
+        resolverURLString: String
+    ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
+        let resolverURL = try validatedResolverURL(from: resolverURLString)
+        var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
+        components.queryItems = [
+            URLQueryItem(name: "name", value: domain),
+            URLQueryItem(name: "type", value: String(queryType))
+        ]
+
+        var request = URLRequest(url: components.url!)
+        request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+
+        guard let httpResponse = response as? HTTPURLResponse,
+              httpResponse.statusCode == 200 else {
+            return try await lookupAnswersViaRFC8484(
+                domain: domain,
+                queryType: queryType,
+                resolverURL: resolverURL
+            )
+        }
+
+        let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
+
+        return dnsResponse.Answer ?? []
+    }
+
+    private static func lookupDNSSECStatus(
+        domain: String,
+        resolverURLString: String
+    ) async throws -> Bool {
+        let answers = try await lookupAnswers(
+            domain: domain,
+            queryType: rrsigQueryType,
+            resolverURLString: resolverURLString
+        )
+        return answers.contains(where: { $0.type == rrsigQueryType })
+    }
+
+    private static func currentResolverURLString() -> String {
+        let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey)
+        return DNSResolverOption.resolvedURLString(from: storedValue)
+    }
+
+    private static func validatedResolverURL(from urlString: String) throws -> URL {
+        guard let url = URL(string: urlString) else {
+            throw URLError(.badURL)
+        }
+        return url
+    }
+
+    private static func lookupAnswersViaRFC8484(
+        domain: String,
+        queryType: Int,
+        resolverURL: URL
+    ) async throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
+        let queryData = try buildDNSQueryMessage(domain: domain, queryType: queryType)
+        let encodedQuery = base64URLEncodedString(for: queryData)
+
+        var components = URLComponents(url: resolverURL, resolvingAgainstBaseURL: false)!
+        components.queryItems = [URLQueryItem(name: "dns", value: encodedQuery)]
+
+        var request = URLRequest(url: components.url!)
+        request.setValue("application/dns-message", forHTTPHeaderField: "Accept")
+
+        let (data, response) = try await URLSession.shared.data(for: request)
+
+        guard let httpResponse = response as? HTTPURLResponse,
+              httpResponse.statusCode == 200 else {
+            throw URLError(.badServerResponse)
+        }
+
+        return try parseDNSMessage(data)
+    }
+
+    private static func buildDNSQueryMessage(domain: String, queryType: Int) throws -> Data {
+        let normalizedName = domain.trimmingCharacters(in: .whitespacesAndNewlines)
+        let labels = normalizedName.split(separator: ".")
+
+        var data = Data()
+        data.appendUInt16(UInt16.random(in: UInt16.min ... UInt16.max))
+        data.appendUInt16(0x0100)
+        data.appendUInt16(1)
+        data.appendUInt16(0)
+        data.appendUInt16(0)
+        data.appendUInt16(0)
+
+        for label in labels {
+            guard let labelData = label.data(using: .utf8),
+                  labelData.count <= 63 else {
+                throw URLError(.badURL)
+            }
+            data.append(UInt8(labelData.count))
+            data.append(labelData)
+        }
+
+        data.append(0)
+        data.appendUInt16(UInt16(queryType))
+        data.appendUInt16(UInt16(internetClass))
+
+        return data
+    }
+
+    private static func base64URLEncodedString(for data: Data) -> String {
+        data.base64EncodedString()
+            .replacingOccurrences(of: "+", with: "-")
+            .replacingOccurrences(of: "/", with: "_")
+            .replacingOccurrences(of: "=", with: "")
+    }
+
+    private static func parseDNSMessage(_ data: Data) throws -> [CloudflareDNSResponse.CloudflareDNSAnswer] {
+        guard data.count >= 12 else {
+            throw URLError(.cannotParseResponse)
+        }
+
+        let answerCount = Int(readUInt16(in: data, at: 6))
+        let questionCount = Int(readUInt16(in: data, at: 4))
+        var offset = 12
+
+        for _ in 0 ..< questionCount {
+            _ = try readDomainName(in: data, offset: &offset)
+            offset += 4
+        }
+
+        var answers: [CloudflareDNSResponse.CloudflareDNSAnswer] = []
+        for _ in 0 ..< answerCount {
+            let name = try readDomainName(in: data, offset: &offset)
+            let type = Int(readUInt16(in: data, at: offset))
+            offset += 2
+            _ = readUInt16(in: data, at: offset)
+            offset += 2
+            let ttl = Int(readUInt32(in: data, at: offset))
+            offset += 4
+            let dataLength = Int(readUInt16(in: data, at: offset))
+            offset += 2
+
+            guard offset + dataLength <= data.count else {
+                throw URLError(.cannotParseResponse)
+            }
+
+            let recordDataOffset = offset
+            let recordData = data.subdata(in: recordDataOffset ..< (recordDataOffset + dataLength))
+            offset += dataLength
+
+            let parsedValue = try parseRecordData(
+                from: data,
+                recordType: type,
+                recordDataOffset: recordDataOffset,
+                recordData: recordData
+            )
+
+            answers.append(.init(
+                name: name,
+                type: type,
+                TTL: ttl,
+                data: parsedValue
+            ))
+        }
+
+        return answers
+    }
+
+    private static func parseRecordData(
+        from message: Data,
+        recordType: Int,
+        recordDataOffset: Int,
+        recordData: Data
+    ) throws -> String {
+        switch recordType {
+        case 1:
+            guard recordData.count == 4 else { throw URLError(.cannotParseResponse) }
+            return recordData.map(String.init).joined(separator: ".")
+        case 2, 5:
+            var offset = recordDataOffset
+            return try readDomainName(in: message, offset: &offset)
+        case 15:
+            guard recordData.count >= 3 else { throw URLError(.cannotParseResponse) }
+            let preference = readUInt16(in: recordData, at: 0)
+            var exchangeOffset = recordDataOffset + 2
+            let exchange = try readDomainName(in: message, offset: &exchangeOffset)
+            return "\(preference) \(exchange)"
+        case 16:
+            return try parseTXTData(recordData)
+        case 28:
+            guard recordData.count == 16 else { throw URLError(.cannotParseResponse) }
+            return stride(from: 0, to: 16, by: 2)
+                .map { index in
+                    String(format: "%x", readUInt16(in: recordData, at: index))
+                }
+                .joined(separator: ":")
+        case 6:
+            var offset = recordDataOffset
+            let mname = try readDomainName(in: message, offset: &offset)
+            let rname = try readDomainName(in: message, offset: &offset)
+            let serial = readUInt32(in: message, at: offset)
+            let refresh = readUInt32(in: message, at: offset + 4)
+            let retry = readUInt32(in: message, at: offset + 8)
+            let expire = readUInt32(in: message, at: offset + 12)
+            let minimum = readUInt32(in: message, at: offset + 16)
+            return "\(mname) \(rname) \(serial) \(refresh) \(retry) \(expire) \(minimum)"
+        case 33:
+            guard recordData.count >= 7 else { throw URLError(.cannotParseResponse) }
+            let priority = readUInt16(in: recordData, at: 0)
+            let weight = readUInt16(in: recordData, at: 2)
+            let port = readUInt16(in: recordData, at: 4)
+            var targetOffset = recordDataOffset + 6
+            let target = try readDomainName(in: message, offset: &targetOffset)
+            return "\(priority) \(weight) \(port) \(target)"
+        case 43:
+            guard recordData.count >= 4 else { throw URLError(.cannotParseResponse) }
+            let keyTag = readUInt16(in: recordData, at: 0)
+            let algorithm = recordData[2]
+            let digestType = recordData[3]
+            let digest = recordData.dropFirst(4).map { String(format: "%02X", $0) }.joined()
+            return "\(keyTag) \(algorithm) \(digestType) \(digest)"
+        case 46:
+            return "RRSIG"
+        case 257:
+            guard recordData.count >= 2 else { throw URLError(.cannotParseResponse) }
+            let flags = recordData[0]
+            let tagLength = Int(recordData[1])
+            guard recordData.count >= 2 + tagLength else {
+                throw URLError(.cannotParseResponse)
+            }
+            let tagData = recordData.subdata(in: 2 ..< (2 + tagLength))
+            let valueData = recordData.dropFirst(2 + tagLength)
+            let tag = String(decoding: tagData, as: UTF8.self)
+            let value = String(decoding: valueData, as: UTF8.self)
+            return "\(flags) \(tag) \"\(value)\""
+        default:
+            return recordData.base64EncodedString()
+        }
+    }
+
+    private static func parseTXTData(_ data: Data) throws -> String {
+        var offset = 0
+        var strings: [String] = []
+
+        while offset < data.count {
+            let count = Int(data[offset])
+            offset += 1
+            guard offset + count <= data.count else {
+                throw URLError(.cannotParseResponse)
+            }
+            let stringData = data.subdata(in: offset ..< (offset + count))
+            strings.append(String(decoding: stringData, as: UTF8.self))
+            offset += count
+        }
+
+        return strings.joined()
+    }
+
+    private static func readDomainName(in data: Data, offset: inout Int) throws -> String {
+        var labels: [String] = []
+        var currentOffset = offset
+        var jumped = false
+        var seenOffsets = Set<Int>()
+
+        while true {
+            guard currentOffset < data.count else {
+                throw URLError(.cannotParseResponse)
+            }
+
+            let length = Int(data[currentOffset])
+
+            if length == 0 {
+                if !jumped {
+                    offset = currentOffset + 1
+                }
+                break
+            }
+
+            if length & 0xC0 == 0xC0 {
+                guard currentOffset + 1 < data.count else {
+                    throw URLError(.cannotParseResponse)
+                }
+
+                let pointer = ((length & 0x3F) << 8) | Int(data[currentOffset + 1])
+                guard seenOffsets.insert(pointer).inserted else {
+                    throw URLError(.cannotParseResponse)
+                }
+
+                if !jumped {
+                    offset = currentOffset + 2
+                }
+                currentOffset = pointer
+                jumped = true
+                continue
+            }
+
+            let labelStart = currentOffset + 1
+            let labelEnd = labelStart + length
+            guard labelEnd <= data.count else {
+                throw URLError(.cannotParseResponse)
+            }
+
+            let labelData = data.subdata(in: labelStart ..< labelEnd)
+            labels.append(String(decoding: labelData, as: UTF8.self))
+            currentOffset = labelEnd
+        }
+
+        return labels.joined(separator: ".")
+    }
+
+    private static func readUInt16(in data: Data, at offset: Int) -> UInt16 {
+        let upper = UInt16(data[offset]) << 8
+        let lower = UInt16(data[offset + 1])
+        return upper | lower
+    }
+
+    private static func readUInt32(in data: Data, at offset: Int) -> UInt32 {
+        let first = UInt32(data[offset]) << 24
+        let second = UInt32(data[offset + 1]) << 16
+        let third = UInt32(data[offset + 2]) << 8
+        let fourth = UInt32(data[offset + 3])
+        return first | second | third | fourth
+    }
+}
+
+private extension Data {
+    mutating func appendUInt16(_ value: UInt16) {
+        append(UInt8((value >> 8) & 0xFF))
+        append(UInt8(value & 0xFF))
+    }
 }
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 88bcfa9..cfc60c4 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -9,6 +9,10 @@ enum DNSRecordType: String, CaseIterable, Codable {
     case NS
     case TXT
     case CNAME
+    case SOA
+    case SRV
+    case CAA
+    case DS
 
     var queryType: Int {
         switch self {
@@ -18,6 +22,19 @@ enum DNSRecordType: String, CaseIterable, Codable {
         case .NS: return 2
         case .TXT: return 16
         case .CNAME: return 5
+        case .SOA: return 6
+        case .SRV: return 33
+        case .CAA: return 257
+        case .DS: return 43
+        }
+    }
+
+    var usesRawDataValue: Bool {
+        switch self {
+        case .TXT, .SOA, .DS:
+            return true
+        default:
+            return false
         }
     }
 }
@@ -33,6 +50,7 @@ struct DNSSection: Identifiable, Codable {
     let recordType: DNSRecordType
     var records: [DNSRecord]
     var wildcardRecords: [DNSRecord] = []
+    var dnssecSigned: Bool?
     var error: String?
 }