krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v2.0.0: DomainDig/DomainAvailabilityService.swift · raw
1import Foundation
2
3struct DomainAvailabilityService {
4 private static let suggestionTLDs = ["net", "io", "dev", "app", "co", "org"]
5
6 static func check(domain: String) async -> DomainAvailabilityResult {
7 let normalizedDomain = normalize(domain)
8 guard !normalizedDomain.isEmpty else {
9 return DomainAvailabilityResult(domain: domain, status: .unknown)
10 }
11
12 if await checkViaRDAP(domain: normalizedDomain) == .registered {
13 debugLog("rdap", domain: normalizedDomain, status: .registered)
14 return DomainAvailabilityResult(domain: normalizedDomain, status: .registered)
15 }
16
17 let fallbackStatus = await checkViaDNSFallback(domain: normalizedDomain)
18 let method = fallbackStatus == .registered ? "dns" : "fallback"
19 debugLog(method, domain: normalizedDomain, status: fallbackStatus)
20 return DomainAvailabilityResult(domain: normalizedDomain, status: fallbackStatus)
21 }
22
23 static func suggestions(for domain: String, limit: Int = 6) async -> [DomainSuggestionResult] {
24 let normalizedDomain = normalize(domain)
25 let candidates = suggestionCandidates(for: normalizedDomain, limit: limit)
26 guard !candidates.isEmpty else { return [] }
27
28 var results: [DomainSuggestionResult] = []
29 for candidate in candidates {
30 if Task.isCancelled { break }
31 let result = await check(domain: candidate)
32 results.append(DomainSuggestionResult(domain: result.domain, status: result.status))
33 }
34 return results
35 }
36
37 private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
38 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
39 return nil
40 }
41
42 do {
43 var request = URLRequest(url: url, timeoutInterval: 8)
44 request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
45
46 let (data, response) = try await URLSession.shared.data(for: request)
47 guard let httpResponse = response as? HTTPURLResponse else {
48 return nil
49 }
50
51 switch httpResponse.statusCode {
52 case 200:
53 return isValidRDAPDomainResponse(data) ? .registered : nil
54 case 404:
55 debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org")
56 return nil
57 default:
58 return nil
59 }
60 } catch {
61 debugLog("rdap-error", domain: domain, details: error.localizedDescription)
62 return nil
63 }
64 }
65
66 private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus {
67 do {
68 let aRecords = try await DNSLookupService.lookup(domain: domain, recordType: .A)
69 if !aRecords.isEmpty {
70 return .registered
71 }
72 } catch {
73 debugLog("dns-a-error", domain: domain, details: error.localizedDescription)
74 }
75
76 do {
77 let nsRecords = try await DNSLookupService.lookup(domain: domain, recordType: .NS)
78 if !nsRecords.isEmpty {
79 return .registered
80 }
81 return .unknown
82 } catch {
83 debugLog("dns-ns-error", domain: domain, details: error.localizedDescription)
84 return .unknown
85 }
86 }
87
88 private static func isValidRDAPDomainResponse(_ data: Data) -> Bool {
89 guard
90 let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
91 else {
92 return false
93 }
94
95 if object["ldhName"] as? String != nil {
96 return true
97 }
98
99 if object["objectClassName"] as? String == "domain" {
100 return true
101 }
102
103 if object["handle"] as? String != nil, object["unicodeName"] as? String != nil {
104 return true
105 }
106
107 return false
108 }
109
110 private static func suggestionCandidates(for domain: String, limit: Int) -> [String] {
111 let parts = domain.split(separator: ".")
112 guard parts.count >= 2 else { return [] }
113
114 let base = parts.dropLast().joined(separator: ".")
115 let tld = String(parts.last ?? "")
116
117 var candidates: [String] = []
118 for suggestionTLD in suggestionTLDs where suggestionTLD != tld {
119 candidates.append("\(base).\(suggestionTLD)")
120 if candidates.count == limit {
121 return candidates
122 }
123 }
124
125 if !base.contains("-"), base.count >= 6, candidates.count < limit {
126 let midpoint = base.index(base.startIndex, offsetBy: base.count / 2)
127 let hyphenated = "\(base[..<midpoint])-\(base[midpoint...]).\(tld)"
128 if hyphenated != domain {
129 candidates.append(hyphenated)
130 }
131 }
132
133 return Array(candidates.prefix(limit))
134 }
135
136 private static func normalize(_ domain: String) -> String {
137 domain
138 .trimmingCharacters(in: .whitespacesAndNewlines)
139 .lowercased()
140 }
141
142 private static func debugLog(_ method: String, domain: String, status: DomainAvailabilityStatus) {
143 #if DEBUG
144 print("[Availability] \(domain) -> \(status.rawValue) via \(method)")
145 #endif
146 }
147
148 private static func debugLog(_ method: String, domain: String, details: String) {
149 #if DEBUG
150 print("[Availability] \(domain) -> \(method): \(details)")
151 #endif
152 }
153}