krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.7.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}