diff options
| author | Christian Cleberg <[email protected]> | 2026-04-21 22:08:09 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-21 22:08:09 -0500 |
| commit | 22ecca12b4fe0e0850401754c674632f322d919d (patch) | |
| tree | 52845c3020c2471d607e7a1ad9681de83cbcbb85 /DomainDig/RDAPService.swift | |
| parent | 1f58092dcb67cded75a850db364f3b46d86181f3 (diff) | |
| download | domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.tar.gz domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.tar.bz2 domain-dig-22ecca12b4fe0e0850401754c674632f322d919d.zip | |
feat(v2.3.0): add ownership intelligence and subdomain discovery
* implement RDAP-based ownership section
* add ownership diffing and change classification
* add passive subdomain discovery via certificate transparency
* highlight interesting subdomains
* introduce Data+ scaffolding for future features
* include ownership and subdomains in export and history
Diffstat (limited to 'DomainDig/RDAPService.swift')
| -rw-r--r-- | DomainDig/RDAPService.swift | 288 |
1 files changed, 288 insertions, 0 deletions
diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift new file mode 100644 index 0000000..c38a140 --- /dev/null +++ b/DomainDig/RDAPService.swift @@ -0,0 +1,288 @@ +import Foundation + +enum RDAPService { + static func registrationStatus(for domain: String) async -> DomainAvailabilityStatus? { + let normalizedDomain = normalize(domain) + guard !normalizedDomain.isEmpty else { return nil } + + switch await cache.response(for: normalizedDomain) { + case let .success(response): + return response.isDomainRecord ? .registered : nil + case .empty: + return nil + case .error: + return nil + } + } + + static func ownership(for domain: String) async -> ServiceResult<DomainOwnership> { + let normalizedDomain = normalize(domain) + guard !normalizedDomain.isEmpty else { + return .empty("Unavailable") + } + + switch await cache.response(for: normalizedDomain) { + case let .success(response): + let ownership = DomainOwnership( + registrar: response.registrarName, + createdDate: response.createdDate, + expirationDate: response.expirationDate, + status: response.status, + nameservers: response.nameservers, + abuseEmail: response.abuseEmail + ) + return .success(ownership) + case let .empty(message): + return .empty(message) + case let .error(message): + return .error(message) + } + } + + private static let cache = RDAPCache() + + private static func normalize(_ domain: String) -> String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + } +} + +private actor RDAPCache { + private var cachedResponses: [String: ServiceResult<RDAPDomainResponse>] = [:] + private var inFlightTasks: [String: Task<ServiceResult<RDAPDomainResponse>, Never>] = [:] + + func response(for domain: String) async -> ServiceResult<RDAPDomainResponse> { + if let cachedResponse = cachedResponses[domain] { + return cachedResponse + } + + if let inFlightTask = inFlightTasks[domain] { + return await inFlightTask.value + } + + let task = Task<ServiceResult<RDAPDomainResponse>, Never> { + await fetchRDAPResponse(for: domain) + } + inFlightTasks[domain] = task + + let result = await task.value + cachedResponses[domain] = result + inFlightTasks[domain] = nil + return result + } +} + +private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { + guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { + return .error("Unavailable") + } + + do { + var request = URLRequest(url: url, timeoutInterval: 8) + request.setValue("application/rdap+json, application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + return .error(URLError(.badServerResponse).localizedDescription) + } + + switch httpResponse.statusCode { + case 200: + let decoder = JSONDecoder() + let rdapResponse = try decoder.decode(RDAPDomainResponse.self, from: data) + guard rdapResponse.isDomainRecord else { + return .empty("Unavailable") + } + return .success(rdapResponse) + case 404: + return .empty("Unavailable") + default: + return .error("Unavailable") + } + } catch { + return .error(error.localizedDescription) + } +} + +private struct RDAPDomainResponse: Decodable, Sendable { + let ldhName: String? + let objectClassName: String? + let unicodeName: String? + let handle: String? + let rawStatus: [String]? + let rawNameservers: [RDAPNameserver]? + let events: [RDAPEvent]? + let entities: [RDAPEntity]? + + enum CodingKeys: String, CodingKey { + case ldhName + case objectClassName + case unicodeName + case handle + case rawStatus = "status" + case rawNameservers = "nameservers" + case events + case entities + } + + var isDomainRecord: Bool { + if ldhName?.isEmpty == false { + return true + } + if objectClassName == "domain" { + return true + } + return handle != nil && unicodeName != nil + } + + var registrarName: String? { + entities?.first(where: { $0.roles.contains("registrar") })?.bestDisplayName + } + + var createdDate: Date? { + eventDate(for: ["registration", "registered"]) + } + + var expirationDate: Date? { + eventDate(for: ["expiration", "expiry", "expired"]) + } + + var abuseEmail: String? { + entities?.first(where: { $0.roles.contains("abuse") })?.email + ?? entities?.first(where: { $0.roles.contains("registrar") })?.abuseEntity?.email + } + + var nameservers: [String] { + let rawValues = rawNameservers?.compactMap { $0.ldhName ?? $0.unicodeName } ?? [] + return deduplicated(rawValues) + } + + var status: [String] { + deduplicated(rawStatus ?? []) + } + + private func eventDate(for actions: [String]) -> Date? { + let normalizedActions = Set(actions) + return events? + .first(where: { normalizedActions.contains($0.eventAction.lowercased()) })? + .parsedDate + } + + private func deduplicated(_ values: [String]) -> [String] { + var seen = Set<String>() + return values + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + .filter { seen.insert($0.lowercased()).inserted } + } +} + +private struct RDAPNameserver: Decodable, Sendable { + let ldhName: String? + let unicodeName: String? +} + +private struct RDAPEvent: Decodable, Sendable { + let eventAction: String + let eventDate: String + + var parsedDate: Date? { + RDAPDateParser.parse(eventDate) + } +} + +private struct RDAPEntity: Decodable, Sendable { + let roles: [String] + let vcardArray: RDAPVCardArray? + let entities: [RDAPEntity]? + + var bestDisplayName: String? { + vcardArray?.fullName ?? vcardArray?.organization ?? vcardArray?.email + } + + var email: String? { + vcardArray?.email + } + + var abuseEntity: RDAPEntity? { + entities?.first(where: { $0.roles.contains("abuse") }) + } +} + +private struct RDAPVCardArray: Decodable, Sendable { + let values: [[RDAPJSONValue]] + + init(from decoder: Decoder) throws { + var container = try decoder.unkeyedContainer() + _ = try container.decode(String.self) + values = try container.decode([[RDAPJSONValue]].self) + } + + var fullName: String? { + value(for: "fn") + } + + var organization: String? { + value(for: "org") + } + + var email: String? { + value(for: "email") + } + + private func value(for key: String) -> String? { + values.first(where: { $0.first?.stringValue?.lowercased() == key })?.last?.stringValue?.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + +private enum RDAPJSONValue: Decodable, Sendable { + case string(String) + case bool(Bool) + case number(Double) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode(Double.self) { + self = .number(value) + } else { + self = .null + } + } + + var stringValue: String? { + switch self { + case let .string(value): + return value + case let .bool(value): + return value ? "true" : "false" + case let .number(value): + return String(value) + case .null: + return nil + } + } +} + +private enum RDAPDateParser { + private static let iso8601WithFractional: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter + }() + + private static let iso8601: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter + }() + + static func parse(_ value: String) -> Date? { + iso8601WithFractional.date(from: value) ?? iso8601.date(from: value) + } +} |
