summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-03 16:51:18 -0500
committerChristian Cleberg <[email protected]>2026-04-03 16:51:18 -0500
commit9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41 (patch)
treefb2473ad6257a4b1a1b3392d377ef6efd3291a9a /DomainDig
parentede15c586af71b8d6f2c190ffdad0324414cac06 (diff)
downloaddomain-dig-9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41.tar.gz
domain-dig-9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41.tar.bz2
domain-dig-9c691f6c6c579a3fbb3bd5bbd74eb0e92ca0bd41.zip
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
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/ContentView.swift87
-rw-r--r--DomainDig/DNSLookupService.swift462
-rw-r--r--DomainDig/Models.swift18
3 files changed, 536 insertions, 31 deletions
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?
}