krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.5.0: DomainDig/RDAPService.swift · raw

  1import Foundation
  2
  3enum RDAPService {
  4    static func registrationStatus(for domain: String) async -> DomainAvailabilityStatus? {
  5        let normalizedDomain = normalize(domain)
  6        guard !normalizedDomain.isEmpty else { return nil }
  7
  8        switch await fetchRDAPResponse(for: normalizedDomain) {
  9        case let .success(response):
 10            return response.isDomainRecord ? .registered : nil
 11        case .empty:
 12            return nil
 13        case .error:
 14            return nil
 15        }
 16    }
 17
 18    static func ownership(for domain: String) async -> ServiceResult<DomainOwnership> {
 19        let normalizedDomain = normalize(domain)
 20        guard !normalizedDomain.isEmpty else {
 21            return .empty("Unavailable")
 22        }
 23
 24        switch await fetchRDAPResponse(for: normalizedDomain) {
 25        case let .success(response):
 26            let ownership = DomainOwnership(
 27                registrar: response.registrarName,
 28                registrant: response.registrantName,
 29                createdDate: response.createdDate,
 30                expirationDate: response.expirationDate,
 31                status: response.status,
 32                nameservers: response.nameservers,
 33                abuseEmail: response.abuseEmail
 34            )
 35            return .success(ownership)
 36        case let .empty(message):
 37            return .empty(message)
 38        case let .error(message):
 39            return .error(message)
 40        }
 41    }
 42
 43    private static func normalize(_ domain: String) -> String {
 44        domain
 45            .trimmingCharacters(in: .whitespacesAndNewlines)
 46            .lowercased()
 47    }
 48}
 49
 50private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
 51    let startedAt = DomainDebugLog.signpostStart("RDAP.fetch", domain: domain)
 52    guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
 53        return .error("Unavailable")
 54    }
 55
 56    do {
 57        var request = URLRequest(url: url, timeoutInterval: 8)
 58        request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept")
 59        DomainDebugLog.debug("RDAP.request url=\(url.absoluteString) timeout=8")
 60
 61        let (data, response) = try await URLSession.shared.data(for: request)
 62        guard let httpResponse = response as? HTTPURLResponse else {
 63            DomainDebugLog.error("RDAP.badResponse domain=\(domain) response=nil")
 64            return .error(URLError(.badServerResponse).localizedDescription)
 65        }
 66
 67        switch httpResponse.statusCode {
 68        case 200:
 69            let decoder = JSONDecoder()
 70            let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data)
 71            guard rdapResponse.isDomainRecord else {
 72                DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 nonDomainRecord")
 73                return .empty("Unavailable")
 74            }
 75            DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=200 bytes=\(data.count)")
 76            return .success(rdapResponse)
 77        case 404:
 78            DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=404")
 79            return .empty("Unavailable")
 80        default:
 81            DomainDebugLog.error("RDAP.httpError domain=\(domain) status=\(httpResponse.statusCode)")
 82            DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "status=\(httpResponse.statusCode)")
 83            return .error("Unavailable")
 84        }
 85    } catch {
 86        DomainDebugLog.error("RDAP.error domain=\(domain) error=\(error.localizedDescription)")
 87        DomainDebugLog.signpostEnd("RDAP.fetch", start: startedAt, domain: domain, extra: "error")
 88        return .error(error.localizedDescription)
 89    }
 90}
 91
 92private struct RDAPDomainResponse: Decodable, Sendable {
 93    let ldhName: String?
 94    let objectClassName: String?
 95    let unicodeName: String?
 96    let handle: String?
 97    let rawStatus: [String]?
 98    let rawNameservers: [RDAPNameserver]?
 99    let events: [RDAPEvent]?
100    let entities: [RDAPEntity]?
101
102    enum CodingKeys: String, CodingKey {
103        case ldhName
104        case objectClassName
105        case unicodeName
106        case handle
107        case rawStatus = "status"
108        case rawNameservers = "nameservers"
109        case events
110        case entities
111    }
112
113    var isDomainRecord: Bool {
114        if ldhName?.isEmpty == false {
115            return true
116        }
117        if objectClassName == "domain" {
118            return true
119        }
120        return handle != nil && unicodeName != nil
121    }
122
123    var registrarName: String? {
124        entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName
125    }
126
127    var registrantName: String? {
128        entities?.first(where: { $0.roles.contains("registrant") })?.bestDisplayName
129    }
130
131    var createdDate: Date? {
132        eventDate(for: ["registration", "registered"])
133    }
134
135    var expirationDate: Date? {
136        eventDate(for: ["expiration", "expiry", "expired"])
137    }
138
139    var abuseEmail: String? {
140        entities?.first(where: { $0.roles.contains("abuse") })?.email
141            ?? entities?.first(where: { $0.roles.contains("registrar") })?.abuseEntity?.email
142    }
143
144    var nameservers: [String] {
145        let rawValues = rawNameservers?.compactMap { $0.ldhName ?? $0.unicodeName } ?? []
146        return deduplicated(rawValues)
147    }
148
149    var status: [String] {
150        deduplicated(rawStatus ?? [])
151    }
152
153    private func eventDate(for actions: [String]) -> Date? {
154        let normalizedActions = Set(actions)
155        return events?
156            .first(where: { normalizedActions.contains($0.eventAction.lowercased()) })?
157            .parsedDate
158    }
159
160    private func deduplicated(_ values: [String]) -> [String] {
161        var seen = Set<String>()
162        return values
163            .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
164            .filter { !$0.isEmpty }
165            .filter { seen.insert($0.lowercased()).inserted }
166    }
167}
168
169private struct RDAPNameserver: Decodable, Sendable {
170    let ldhName: String?
171    let unicodeName: String?
172}
173
174private struct RDAPEvent: Decodable, Sendable {
175    let eventAction: String
176    let eventDate: String
177
178    var parsedDate: Date? {
179        RDAPDateParser.parse(eventDate)
180    }
181}
182
183private struct RDAPEntity: Decodable, Sendable {
184    let roles: [String]
185    let vcardArray: RDAPVCardArray?
186    let entities: [RDAPEntity]?
187
188    var bestDisplayName: String? {
189        vcardArray?.fullName ?? vcardArray?.organization ?? vcardArray?.email
190    }
191
192    var email: String? {
193        vcardArray?.email
194    }
195
196    var abuseEntity: RDAPEntity? {
197        entities?.first(where: { $0.roles.contains("abuse") })
198    }
199}
200
201private struct RDAPVCardArray: Decodable, Sendable {
202    let values: [[RDAPJSONValue]]
203
204    init(from decoder: Decoder) throws {
205        var container = try decoder.unkeyedContainer()
206        _ = try container.decode(String.self)
207        values = try container.decode([[RDAPJSONValue]].self)
208    }
209
210    var fullName: String? {
211        value(for: "fn")
212    }
213
214    var organization: String? {
215        value(for: "org")
216    }
217
218    var email: String? {
219        value(for: "email")
220    }
221
222    private func value(for key: String) -> String? {
223        values.first(where: { $0.first?.stringValue?.lowercased() == key })?.last?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines)
224    }
225}
226
227private enum RDAPJSONValue: Decodable, Sendable {
228    case string(String)
229    case bool(Bool)
230    case number(Double)
231    case null
232
233    init(from decoder: Decoder) throws {
234        let container = try decoder.singleValueContainer()
235        if let value = try? container.decode(String.self) {
236            self = .string(value)
237        } else if let value = try? container.decode(Bool.self) {
238            self = .bool(value)
239        } else if let value = try? container.decode(Double.self) {
240            self = .number(value)
241        } else {
242            self = .null
243        }
244    }
245
246    var stringValue: String? {
247        switch self {
248        case let .string(value):
249            return value
250        case let .bool(value):
251            return value ? "true" : "false"
252        case let .number(value):
253            return String(value)
254        case .null:
255            return nil
256        }
257    }
258}
259
260private enum RDAPDateParser {
261    private static let iso8601WithFractional: ISO8601DateFormatter = {
262        let formatter = ISO8601DateFormatter()
263        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
264        return formatter
265    }()
266
267    private static let iso8601: ISO8601DateFormatter = {
268        let formatter = ISO8601DateFormatter()
269        formatter.formatOptions = [.withInternetDateTime]
270        return formatter
271    }()
272
273    static func parse(_ value: String) -> Date? {
274        iso8601WithFractional.date(from: value) ?? iso8601.date(from: value)
275    }
276}