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}