summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-21 22:08:09 -0500
committerChristian Cleberg <[email protected]>2026-04-21 22:08:09 -0500
commit22ecca12b4fe0e0850401754c674632f322d919d (patch)
tree52845c3020c2471d607e7a1ad9681de83cbcbb85
parent1f58092dcb67cded75a850db364f3b46d86181f3 (diff)
downloaddomain-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
-rw-r--r--DomainDig.xcodeproj/project.pbxproj8
-rw-r--r--DomainDig/BatchResultsView.swift3
-rw-r--r--DomainDig/BatchSweepSummaryView.swift5
-rw-r--r--DomainDig/ContentView.swift100
-rw-r--r--DomainDig/DataAccessService.swift7
-rw-r--r--DomainDig/DomainAvailabilityService.swift51
-rw-r--r--DomainDig/DomainDiffService.swift104
-rw-r--r--DomainDig/DomainOwnershipService.swift7
-rw-r--r--DomainDig/DomainViewModel.swift214
-rw-r--r--DomainDig/HistoryView.swift14
-rw-r--r--DomainDig/Models.swift70
-rw-r--r--DomainDig/RDAPService.swift288
-rw-r--r--DomainDig/SubdomainDiscoveryService.swift118
13 files changed, 923 insertions, 66 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 8e652f6..6ce65e4 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -267,7 +267,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 15;
+ CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +284,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.2.0;
+ MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +303,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 15;
+ CURRENT_PROJECT_VERSION = 16;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +320,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.2.0;
+ MARKETING_VERSION = 2.3.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
index 03bc7c4..6bf18c0 100644
--- a/DomainDig/BatchResultsView.swift
+++ b/DomainDig/BatchResultsView.swift
@@ -115,6 +115,9 @@ struct BatchResultRowView: View {
if result.changeSeverity == .medium || result.certificateWarningLevel == .warning {
return .yellow
}
+ if result.quickStatus == "Changed" {
+ return .blue
+ }
return .green
case .failed:
return .red
diff --git a/DomainDig/BatchSweepSummaryView.swift b/DomainDig/BatchSweepSummaryView.swift
index fda2bc5..61b3e37 100644
--- a/DomainDig/BatchSweepSummaryView.swift
+++ b/DomainDig/BatchSweepSummaryView.swift
@@ -12,7 +12,10 @@ struct BatchSweepSummaryView: View {
}
return summary.results.filter {
- ($0.changeSeverity ?? .low) >= .medium || $0.certificateWarningLevel != .none || $0.status == .failed
+ $0.quickStatus == "Changed" ||
+ $0.quickStatus == "High" ||
+ $0.certificateWarningLevel != .none ||
+ $0.status == .failed
}
}
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 2be81b3..261b7ca 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -60,6 +60,20 @@ struct ContentView: View {
}
)
.padding(.top, 16)
+ OwnershipSectionView(
+ rows: viewModel.ownershipRows,
+ loading: viewModel.ownershipLoading,
+ error: viewModel.ownershipError,
+ showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
+ )
+ .padding(.top, 16)
+ SubdomainsSectionView(
+ rows: viewModel.subdomainRows,
+ loading: viewModel.subdomainsLoading,
+ error: viewModel.subdomainsError,
+ showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
+ )
+ .padding(.top, 16)
if !viewModel.currentDiffSections.isEmpty {
DomainDiffView(
title: "Latest Changes",
@@ -772,6 +786,92 @@ struct DomainSectionView: View {
}
}
+struct OwnershipSectionView: View {
+ let rows: [InfoRowViewData]
+ let loading: Bool
+ let error: String?
+ let showsHistoryPlaceholder: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ SectionTitleView(title: "Ownership")
+ CardView(allowsHorizontalScroll: false) {
+ if loading {
+ ProgressView("Fetching RDAP ownership…")
+ .appLoadingStyle()
+ } else {
+ ForEach(rows) { row in
+ LabeledValueRow(row: row)
+ }
+ if let error, rows.allSatisfy({ $0.value == "Unavailable" }) {
+ MessageRowView(text: error, isError: error != "Unavailable")
+ .padding(.top, 4)
+ }
+ if showsHistoryPlaceholder {
+ MessageRowView(text: "Ownership history (coming soon)", isError: false)
+ .padding(.top, 4)
+ }
+ }
+ }
+ }
+ }
+}
+
+struct SubdomainsSectionView: View {
+ let rows: [SubdomainRowViewData]
+ let loading: Bool
+ let error: String?
+ let showsExtendedPlaceholder: Bool
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 12) {
+ HStack {
+ SectionTitleView(title: "Subdomains")
+ Spacer()
+ Text("\(rows.count)")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+
+ CardView(allowsHorizontalScroll: false) {
+ if loading {
+ ProgressView("Checking certificate transparency…")
+ .appLoadingStyle()
+ } else if rows.isEmpty {
+ MessageRowView(text: error ?? "No passive subdomains found", isError: false)
+ if showsExtendedPlaceholder {
+ MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
+ .padding(.top, 4)
+ }
+ } else {
+ ForEach(rows) { row in
+ HStack(spacing: 8) {
+ Text(row.hostname)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.primary)
+ .textSelection(.enabled)
+ Spacer()
+ if row.isInteresting {
+ Text("Interesting")
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.yellow)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .background(Color.yellow.opacity(0.14))
+ .clipShape(Capsule())
+ }
+ }
+ }
+ if showsExtendedPlaceholder {
+ MessageRowView(text: "Extended subdomain discovery (Data+)", isError: false)
+ .padding(.top, 4)
+ }
+ }
+ }
+ }
+ }
+}
+
struct DNSSectionView: View {
let dnssecLabel: String?
let sections: [DNSRecordSectionViewData]
diff --git a/DomainDig/DataAccessService.swift b/DomainDig/DataAccessService.swift
new file mode 100644
index 0000000..fed4fde
--- /dev/null
+++ b/DomainDig/DataAccessService.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+enum DataAccessService {
+ static func hasAccess(to capability: DataCapability) -> Bool {
+ false
+ }
+}
diff --git a/DomainDig/DomainAvailabilityService.swift b/DomainDig/DomainAvailabilityService.swift
index 7bef586..9a350ef 100644
--- a/DomainDig/DomainAvailabilityService.swift
+++ b/DomainDig/DomainAvailabilityService.swift
@@ -35,32 +35,11 @@ struct DomainAvailabilityService {
}
private static func checkViaRDAP(domain: String) async -> DomainAvailabilityStatus? {
- guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
- return nil
- }
-
- 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 nil
- }
-
- switch httpResponse.statusCode {
- case 200:
- return isValidRDAPDomainResponse(data) ? .registered : nil
- case 404:
- debugLog("rdap-not-found", domain: domain, details: "Ignoring not-found response from rdap.org")
- return nil
- default:
- return nil
- }
- } catch {
- debugLog("rdap-error", domain: domain, details: error.localizedDescription)
- return nil
+ let status = await RDAPService.registrationStatus(for: domain)
+ if status == nil {
+ debugLog("rdap-not-found", domain: domain, details: "Ignoring unavailable response from rdap.org")
}
+ return status
}
private static func checkViaDNSFallback(domain: String) async -> DomainAvailabilityStatus {
@@ -85,28 +64,6 @@ struct DomainAvailabilityService {
}
}
- private static func isValidRDAPDomainResponse(_ data: Data) -> Bool {
- guard
- let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
- else {
- return false
- }
-
- if object["ldhName"] as? String != nil {
- return true
- }
-
- if object["objectClassName"] as? String == "domain" {
- return true
- }
-
- if object["handle"] as? String != nil, object["unicodeName"] as? String != nil {
- return true
- }
-
- return false
- }
-
private static func suggestionCandidates(for domain: String, limit: Int) -> [String] {
let parts = domain.split(separator: ".")
guard parts.count >= 2 else { return [] }
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift
index 667802a..340873d 100644
--- a/DomainDig/DomainDiffService.swift
+++ b/DomainDig/DomainDiffService.swift
@@ -43,11 +43,13 @@ enum DomainDiffService {
[
availabilitySection(from: oldSnapshot, to: newSnapshot),
primaryIPSection(from: oldSnapshot, to: newSnapshot),
+ ownershipSection(from: oldSnapshot, to: newSnapshot),
dnsSection(from: oldSnapshot, to: newSnapshot),
redirectSection(from: oldSnapshot, to: newSnapshot),
tlsSection(from: oldSnapshot, to: newSnapshot),
httpSection(from: oldSnapshot, to: newSnapshot),
- emailSection(from: oldSnapshot, to: newSnapshot)
+ emailSection(from: oldSnapshot, to: newSnapshot),
+ subdomainSection(from: oldSnapshot, to: newSnapshot)
]
.filter { !$0.items.isEmpty }
}
@@ -58,19 +60,16 @@ enum DomainDiffService {
generatedAt: Date = Date()
) -> DomainChangeSummary {
let sections = diff(from: oldSnapshot, to: newSnapshot)
- let meaningfulItems = sections
- .flatMap(\.items)
- .filter(\.isMeaningful)
let allChangedItems = sections
.flatMap(\.items)
.filter(\.hasChanges)
- let highlights = summaryHighlights(from: meaningfulItems)
- let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low)
- let message = summaryMessage(from: meaningfulItems, highlights: highlights)
+ let highlights = summaryHighlights(from: allChangedItems)
+ let severity = allChangedItems.map(\.severity).max() ?? .low
+ let message = summaryMessage(from: allChangedItems, highlights: highlights)
return DomainChangeSummary(
- hasChanges: !meaningfulItems.isEmpty,
+ hasChanges: !allChangedItems.isEmpty,
changedSections: highlights,
message: message,
severity: severity,
@@ -152,6 +151,50 @@ enum DomainDiffService {
return DomainDiffSection(title: "DNS", items: items)
}
+ private static func ownershipSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Ownership",
+ items: [
+ compare(
+ label: "Registrar",
+ oldValue: normalized(oldSnapshot.ownership?.registrar),
+ newValue: normalized(newSnapshot.ownership?.registrar),
+ severity: .high
+ ),
+ compare(
+ label: "Registration Date",
+ oldValue: ownershipDateLabel(oldSnapshot.ownership?.createdDate),
+ newValue: ownershipDateLabel(newSnapshot.ownership?.createdDate),
+ severity: .low
+ ),
+ compare(
+ label: "Expiration Date",
+ oldValue: ownershipDateLabel(oldSnapshot.ownership?.expirationDate),
+ newValue: ownershipDateLabel(newSnapshot.ownership?.expirationDate),
+ severity: .low
+ ),
+ compare(
+ label: "Ownership Status",
+ oldValue: ownershipList(oldSnapshot.ownership?.status),
+ newValue: ownershipList(newSnapshot.ownership?.status),
+ severity: .low
+ ),
+ compare(
+ label: "Nameservers",
+ oldValue: ownershipList(oldSnapshot.ownership?.nameservers),
+ newValue: ownershipList(newSnapshot.ownership?.nameservers),
+ severity: .medium
+ ),
+ compare(
+ label: "Abuse Contact",
+ oldValue: normalized(oldSnapshot.ownership?.abuseEmail),
+ newValue: normalized(newSnapshot.ownership?.abuseEmail),
+ severity: .low
+ )
+ ].compactMap { $0 }
+ )
+ }
+
private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
DomainDiffSection(
title: "Redirect",
@@ -252,6 +295,20 @@ enum DomainDiffService {
)
}
+ private static func subdomainSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection {
+ DomainDiffSection(
+ title: "Subdomains",
+ items: [
+ compare(
+ label: "Passive Subdomains",
+ oldValue: subdomainList(from: oldSnapshot),
+ newValue: subdomainList(from: newSnapshot),
+ severity: .low
+ )
+ ].compactMap { $0 }
+ )
+ }
+
private static func compare(
label: String,
oldValue: String?,
@@ -306,6 +363,13 @@ enum DomainDiffService {
if labels.contains("Redirect Target") {
highlights.append("Redirect target changed")
}
+ if labels.contains("Registrar") {
+ highlights.append("Registrar changed")
+ } else if labels.contains("Nameservers") {
+ highlights.append("Nameservers changed")
+ } else if labels.contains("Expiration Date") || labels.contains("Registration Date") || labels.contains("Ownership Status") || labels.contains("Abuse Contact") {
+ highlights.append("Ownership metadata changed")
+ }
if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }),
let message = certificateItem.newValue {
highlights.append(message)
@@ -323,6 +387,9 @@ enum DomainDiffService {
if labels.contains("Email Security") {
highlights.append("Email security changed")
}
+ if labels.contains("Passive Subdomains") {
+ highlights.append("Subdomains changed")
+ }
var deduplicated: [String] = []
for highlight in highlights where !deduplicated.contains(highlight) {
@@ -380,6 +447,10 @@ enum DomainDiffService {
return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)"
}
+ private static func ownershipDateLabel(_ date: Date?) -> String? {
+ date?.formatted(date: .abbreviated, time: .omitted)
+ }
+
private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? {
if let httpStatusCode = snapshot.httpStatusCode {
return "\(httpStatusCode)"
@@ -423,4 +494,21 @@ enum DomainDiffService {
.sorted()
return headers.isEmpty ? nil : headers.joined(separator: "|")
}
+
+ private static func ownershipList(_ values: [String]?) -> String? {
+ guard let values else { return nil }
+ let normalizedValues = values
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .filter { !$0.isEmpty }
+ .sorted()
+ return normalizedValues.isEmpty ? nil : normalizedValues.joined(separator: ",")
+ }
+
+ private static func subdomainList(from snapshot: LookupSnapshot) -> String? {
+ let values = snapshot.subdomains
+ .map(\.hostname)
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+ .sorted()
+ return values.isEmpty ? nil : values.joined(separator: ",")
+ }
}
diff --git a/DomainDig/DomainOwnershipService.swift b/DomainDig/DomainOwnershipService.swift
new file mode 100644
index 0000000..2c7b738
--- /dev/null
+++ b/DomainDig/DomainOwnershipService.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+enum DomainOwnershipService {
+ static func lookup(domain: String) async -> ServiceResult<DomainOwnership> {
+ await RDAPService.ownership(for: domain)
+ }
+}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 031c062..e895ce6 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -72,6 +72,18 @@ struct PortScanRowViewData: Identifiable {
let durationLabel: String?
}
+struct SubdomainRowViewData: Identifiable {
+ let id: String
+ let hostname: String
+ let isInteresting: Bool
+
+ init(hostname: String, isInteresting: Bool) {
+ self.id = hostname
+ self.hostname = hostname
+ self.isInteresting = isInteresting
+ }
+}
+
struct DomainSuggestionViewData: Identifiable {
let id: UUID
let domain: String
@@ -107,10 +119,14 @@ struct LookupSnapshot {
let ipGeolocationError: String?
let emailSecurity: EmailSecurityResult?
let emailSecurityError: String?
+ let ownership: DomainOwnership?
+ let ownershipError: String?
let ptrRecord: String?
let ptrError: String?
let redirectChain: [RedirectHop]
let redirectChainError: String?
+ let subdomains: [DiscoveredSubdomain]
+ let subdomainsError: String?
let portScanResults: [PortScanResult]
let portScanError: String?
let changeSummary: DomainChangeSummary?
@@ -147,10 +163,14 @@ extension HistoryEntry {
ipGeolocationError: ipGeolocationError,
emailSecurity: emailSecurity,
emailSecurityError: emailSecurityError,
+ ownership: ownership,
+ ownershipError: ownershipError,
ptrRecord: ptrRecord,
ptrError: ptrError,
redirectChain: redirectChain,
redirectChainError: redirectChainError,
+ subdomains: subdomains,
+ subdomainsError: subdomainsError,
portScanResults: portScanResults,
portScanError: portScanError,
changeSummary: changeSummary,
@@ -204,6 +224,10 @@ final class DomainViewModel {
var emailSecurityLoading = false
var emailSecurityError: String?
+ var ownershipResult: DomainOwnership?
+ var ownershipLoading = false
+ var ownershipError: String?
+
var ptrRecord: String?
var ptrLoading = false
var ptrError: String?
@@ -212,6 +236,10 @@ final class DomainViewModel {
var redirectChainLoading = false
var redirectChainError: String?
+ var subdomains: [DiscoveredSubdomain] = []
+ var subdomainsLoading = false
+ var subdomainsError: String?
+
var portScanResults: [PortScanResult] = []
var portScanLoading = false
var portScanError: String?
@@ -224,6 +252,7 @@ final class DomainViewModel {
private(set) var lastLookupDurationMs: Int?
private(set) var currentDiffSections: [DomainDiffSection] = []
private(set) var currentChangeSummary: DomainChangeSummary?
+ private(set) var ownershipDiff: [DomainDiffItem] = []
private(set) var refreshingTrackedDomainID: UUID?
private(set) var rerunNavigationToken = UUID()
private(set) var batchResults: [BatchLookupResult] = []
@@ -290,8 +319,10 @@ final class DomainViewModel {
!reachabilityLoading &&
!ipGeolocationLoading &&
!emailSecurityLoading &&
+ !ownershipLoading &&
!ptrLoading &&
!redirectChainLoading &&
+ !subdomainsLoading &&
!portScanLoading &&
!customPortScanLoading
}
@@ -438,10 +469,14 @@ final class DomainViewModel {
ipGeolocationError: ipGeolocationError,
emailSecurity: emailSecurity,
emailSecurityError: emailSecurityError,
+ ownership: ownershipResult,
+ ownershipError: ownershipError,
ptrRecord: ptrRecord,
ptrError: ptrError,
redirectChain: redirectChain,
redirectChainError: redirectChainError,
+ subdomains: subdomains,
+ subdomainsError: subdomainsError,
portScanResults: allPortScanResults,
portScanError: combinedPortScanError,
changeSummary: currentChangeSummary,
@@ -489,6 +524,14 @@ final class DomainViewModel {
Self.emailRows(from: currentSnapshot)
}
+ var ownershipRows: [InfoRowViewData] {
+ Self.ownershipRows(from: currentSnapshot)
+ }
+
+ var subdomainRows: [SubdomainRowViewData] {
+ Self.subdomainRows(from: currentSnapshot)
+ }
+
var reachabilityRows: [ReachabilityRowViewData] {
Self.reachabilityRows(from: currentSnapshot)
}
@@ -643,6 +686,7 @@ final class DomainViewModel {
lastLookupDurationMs = nil
currentDiffSections = []
currentChangeSummary = nil
+ ownershipDiff = []
refreshingTrackedDomainID = nil
clearBatchState()
clearLookupState()
@@ -791,6 +835,8 @@ final class DomainViewModel {
group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) }
+ group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) }
}
await withTaskGroup(of: Void.self) { group in
@@ -946,6 +992,23 @@ final class DomainViewModel {
emailSecurityLoading = false
}
+ private func runOwnership(domain: String, lookupID: UUID) async {
+ let result = await DomainOwnershipService.lookup(domain: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(ownership):
+ ownershipResult = ownership
+ ownershipError = nil
+ case let .empty(message):
+ ownershipResult = nil
+ ownershipError = message
+ case let .error(message):
+ ownershipResult = nil
+ ownershipError = message
+ }
+ ownershipLoading = false
+ }
+
private func runReverseDNS(ip: String, lookupID: UUID) async {
let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString)
guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
@@ -980,6 +1043,23 @@ final class DomainViewModel {
redirectChainLoading = false
}
+ private func runSubdomains(domain: String, lookupID: UUID) async {
+ let result = await SubdomainDiscoveryService.discover(for: domain)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return }
+ switch result {
+ case let .success(results):
+ subdomains = results
+ subdomainsError = nil
+ case let .empty(message):
+ subdomains = []
+ subdomainsError = message
+ case let .error(message):
+ subdomains = []
+ subdomainsError = message
+ }
+ subdomainsLoading = false
+ }
+
private func runPortScan(domain: String, lookupID: UUID) async {
let result = await PortScanService.scanAll(domain: domain)
switch result {
@@ -1061,7 +1141,9 @@ final class DomainViewModel {
async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
async let httpResult = HTTPHeadersService.fetch(domain: domain)
async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
+ async let ownershipResult = DomainOwnershipService.lookup(domain: domain)
async let redirectResult = RedirectChainService.trace(domain: domain)
+ async let subdomainResult = SubdomainDiscoveryService.discover(for: domain)
async let portScanResult = PortScanService.scanAll(domain: domain)
let resolvedDNS = await dnsResult
@@ -1070,7 +1152,9 @@ final class DomainViewModel {
let hsts = await hstsResult
let http = await httpResult
let reachability = await reachabilityResult
+ let resolvedOwnership = await ownershipResult
let redirects = await redirectResult
+ let resolvedSubdomains = await subdomainResult
let ports = await portScanResult
guard !Task.isCancelled else { return nil }
@@ -1187,6 +1271,17 @@ final class DomainViewModel {
emailSecurityError = message
}
+ let ownership: DomainOwnership?
+ let ownershipError: String?
+ switch resolvedOwnership {
+ case let .success(result):
+ ownership = result
+ ownershipError = nil
+ case let .empty(message), let .error(message):
+ ownership = nil
+ ownershipError = message
+ }
+
let ptrRecord: String?
let ptrError: String?
switch resolvedPTR {
@@ -1215,6 +1310,17 @@ final class DomainViewModel {
ipGeolocationError = "No A record available"
}
+ let subdomains: [DiscoveredSubdomain]
+ let subdomainsError: String?
+ switch resolvedSubdomains {
+ case let .success(result):
+ subdomains = result
+ subdomainsError = nil
+ case let .empty(message), let .error(message):
+ subdomains = []
+ subdomainsError = message
+ }
+
let snapshot = LookupSnapshot(
historyEntryID: nil,
domain: domain,
@@ -1243,10 +1349,14 @@ final class DomainViewModel {
ipGeolocationError: ipGeolocationError,
emailSecurity: emailSecurity,
emailSecurityError: emailSecurityError,
+ ownership: ownership,
+ ownershipError: ownershipError,
ptrRecord: ptrRecord,
ptrError: ptrError,
redirectChain: redirectChain,
redirectChainError: redirectChainError,
+ subdomains: subdomains,
+ subdomainsError: subdomainsError,
portScanResults: portScanResults,
portScanError: portScanError,
changeSummary: nil,
@@ -1303,6 +1413,7 @@ final class DomainViewModel {
if updateCurrentState {
currentChangeSummary = changeSummary
currentDiffSections = diffSections
+ ownershipDiff = diffSections.first(where: { $0.title == "Ownership" })?.items.filter(\.hasChanges) ?? []
}
let entry = HistoryEntry(
@@ -1316,8 +1427,10 @@ final class DomainViewModel {
ipGeolocation: snapshot.ipGeolocation,
emailSecurity: snapshot.emailSecurity,
mtaSts: snapshot.emailSecurity?.mtaSts,
+ ownership: snapshot.ownership,
ptrRecord: snapshot.ptrRecord,
redirectChain: snapshot.redirectChain,
+ subdomains: snapshot.subdomains,
portScanResults: snapshot.portScanResults,
hstsPreloaded: snapshot.hstsPreloaded,
availabilityResult: snapshot.availabilityResult,
@@ -1336,8 +1449,10 @@ final class DomainViewModel {
reachabilityError: snapshot.reachabilityError,
ipGeolocationError: snapshot.ipGeolocationError,
emailSecurityError: snapshot.emailSecurityError,
+ ownershipError: snapshot.ownershipError,
ptrError: snapshot.ptrError,
redirectChainError: snapshot.redirectChainError,
+ subdomainsError: snapshot.subdomainsError,
portScanError: snapshot.portScanError
)
@@ -1520,6 +1635,7 @@ final class DomainViewModel {
hasRun = true
currentDiffSections = []
currentChangeSummary = nil
+ ownershipDiff = []
clearLookupState()
setAllLoadingStates(true)
customPortScanLoading = false
@@ -1677,8 +1793,8 @@ final class DomainViewModel {
let summary = BatchSweepSummary(
source: source,
totalDomains: batchResults.count,
- changedDomains: batchResults.filter { ($0.changeSeverity ?? .low) >= .medium }.count,
- unchangedDomains: batchResults.filter { ($0.changeSeverity ?? .low) < .medium && $0.certificateWarningLevel == .none && $0.status == .completed }.count,
+ changedDomains: batchResults.filter { $0.quickStatus == "Changed" || $0.quickStatus == "High" }.count,
+ unchangedDomains: batchResults.filter { $0.quickStatus == "Unchanged" && $0.status == .completed }.count,
warningDomains: batchResults.filter { $0.certificateWarningLevel != .none }.count,
results: batchResults.sorted { lhs, rhs in
if lhs.status != rhs.status {
@@ -1754,12 +1870,18 @@ final class DomainViewModel {
emailSecurity = nil
emailSecurityError = nil
emailSecurityLoading = false
+ ownershipResult = nil
+ ownershipError = nil
+ ownershipLoading = false
ptrRecord = nil
ptrError = nil
ptrLoading = false
redirectChain = []
redirectChainError = nil
redirectChainLoading = false
+ subdomains = []
+ subdomainsError = nil
+ subdomainsLoading = false
portScanResults = []
portScanError = nil
portScanLoading = false
@@ -1778,8 +1900,10 @@ final class DomainViewModel {
reachabilityLoading = loading
ipGeolocationLoading = loading
emailSecurityLoading = loading
+ ownershipLoading = loading
ptrLoading = loading
redirectChainLoading = loading
+ subdomainsLoading = loading
portScanLoading = loading
}
@@ -1872,10 +1996,14 @@ final class DomainViewModel {
ipGeolocationError: nil,
emailSecurity: nil,
emailSecurityError: nil,
+ ownership: nil,
+ ownershipError: nil,
ptrRecord: nil,
ptrError: nil,
redirectChain: [],
redirectChainError: nil,
+ subdomains: [],
+ subdomainsError: nil,
portScanResults: [],
portScanError: nil,
changeSummary: trackedDomain.lastChangeSummary,
@@ -2100,6 +2228,28 @@ final class DomainViewModel {
]
}
+ static func ownershipRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] {
+ let ownership = snapshot.ownership
+
+ return [
+ InfoRowViewData(label: "Registrar", value: ownership?.registrar ?? "Unavailable", tone: ownership?.registrar == nil ? .secondary : .primary),
+ InfoRowViewData(label: "Registered", value: ownership?.createdDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.createdDate == nil ? .secondary : .primary),
+ InfoRowViewData(label: "Expires", value: ownership?.expirationDate.map(ownershipDateFormatter.string(from:)) ?? "Unavailable", tone: ownership?.expirationDate == nil ? .secondary : .primary),
+ InfoRowViewData(label: "Status", value: ownership?.status.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.status.isEmpty == false ? .primary : .secondary),
+ InfoRowViewData(label: "Nameservers", value: ownership?.nameservers.nilIfEmpty?.joined(separator: ", ") ?? "Unavailable", tone: ownership?.nameservers.isEmpty == false ? .primary : .secondary),
+ InfoRowViewData(label: "Abuse Contact", value: ownership?.abuseEmail ?? "Unavailable", tone: ownership?.abuseEmail == nil ? .secondary : .primary)
+ ]
+ }
+
+ static func subdomainRows(from snapshot: LookupSnapshot) -> [SubdomainRowViewData] {
+ snapshot.subdomains.map { subdomain in
+ SubdomainRowViewData(
+ hostname: subdomain.hostname,
+ isInteresting: isInterestingSubdomain(subdomain.hostname)
+ )
+ }
+ }
+
static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] {
snapshot.reachabilityResults.map {
ReachabilityRowViewData(
@@ -2179,6 +2329,12 @@ final class DomainViewModel {
"tls_status",
"http_status_grade",
"email_security_summary",
+ "registrar",
+ "ownership_expires",
+ "ownership_status",
+ "ownership_nameservers",
+ "subdomain_count",
+ "subdomains",
"last_updated"
]
@@ -2191,6 +2347,12 @@ final class DomainViewModel {
httpsSummary(from: snapshot),
httpStatusGradeSummary(from: snapshot),
emailSummary(from: snapshot),
+ snapshot.ownership?.registrar ?? "",
+ snapshot.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? "",
+ snapshot.ownership?.status.joined(separator: " | ") ?? "",
+ snapshot.ownership?.nameservers.joined(separator: " | ") ?? "",
+ "\(snapshot.subdomains.count)",
+ snapshot.subdomains.map(\.hostname).joined(separator: " | "),
csvDateFormatter.string(from: snapshot.timestamp)
]
}
@@ -2282,6 +2444,33 @@ final class DomainViewModel {
}
}
+ appendSection("Ownership") {
+ for row in ownershipRows(from: snapshot) {
+ lines.append(" \(row.label): \(row.value)")
+ }
+ if let ownershipError = snapshot.ownershipError, snapshot.ownership == nil {
+ lines.append(" Source: \(ownershipError)")
+ }
+ if !DataAccessService.hasAccess(to: .ownershipHistory) {
+ lines.append(" Ownership history (coming soon)")
+ }
+ }
+
+ appendSection("Subdomains") {
+ lines.append(" Count: \(snapshot.subdomains.count)")
+ if snapshot.subdomains.isEmpty {
+ lines.append(" \(snapshot.subdomainsError ?? "No passive subdomains found")")
+ } else {
+ for subdomain in subdomainRows(from: snapshot) {
+ let marker = subdomain.isInteresting ? " [interesting]" : ""
+ lines.append(" \(subdomain.hostname)\(marker)")
+ }
+ }
+ if !DataAccessService.hasAccess(to: .extendedSubdomains) {
+ lines.append(" Extended subdomain discovery (Data+)")
+ }
+ }
+
appendSection("DNS") {
if let dnsError = snapshot.dnsError {
lines.append(" Error: \(dnsError)")
@@ -2544,6 +2733,13 @@ final class DomainViewModel {
return formatter
}()
+ private static let ownershipDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .none
+ return formatter
+ }()
+
private static let csvDateFormatter: ISO8601DateFormatter = {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime]
@@ -2554,6 +2750,14 @@ final class DomainViewModel {
let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
}
+
+ private static func isInterestingSubdomain(_ hostname: String) -> Bool {
+ let keywords = ["admin", "api", "dev", "staging", "test", "internal"]
+ let labels = hostname.lowercased().split(separator: ".").map(String.init)
+ return labels.contains { label in
+ keywords.contains(where: { label.contains($0) })
+ }
+ }
}
private extension String {
@@ -2565,3 +2769,9 @@ private extension String {
isEmpty ? nil : self
}
}
+
+private extension Array where Element == String {
+ var nilIfEmpty: [String]? {
+ isEmpty ? nil : self
+ }
+}
diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift
index 35ec723..6f3d1a9 100644
--- a/DomainDig/HistoryView.swift
+++ b/DomainDig/HistoryView.swift
@@ -147,6 +147,20 @@ struct HistoryDetailView: View {
onEditNote: nil
)
.padding(.top, 16)
+ OwnershipSectionView(
+ rows: DomainViewModel.ownershipRows(from: snapshot),
+ loading: false,
+ error: snapshot.ownershipError,
+ showsHistoryPlaceholder: !DataAccessService.hasAccess(to: .ownershipHistory)
+ )
+ .padding(.top, 16)
+ SubdomainsSectionView(
+ rows: DomainViewModel.subdomainRows(from: snapshot),
+ loading: false,
+ error: snapshot.subdomainsError,
+ showsExtendedPlaceholder: !DataAccessService.hasAccess(to: .extendedSubdomains)
+ )
+ .padding(.top, 16)
if let comparisonSnapshot = viewModel.comparisonSnapshot(for: entry) {
if let changeSummary = entry.changeSummary {
DomainChangeSummaryView(summary: changeSummary)
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index 2e0a5c2..b1e5432 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -184,6 +184,13 @@ struct BatchSweepSummary: Identifiable, Equatable {
let generatedAt: Date
}
+enum DataCapability: String, Codable {
+ case ownershipHistory
+ case dnsHistory
+ case extendedSubdomains
+ case domainPricing
+}
+
enum HistoryDateFilter: String, CaseIterable, Identifiable {
case today
case last7Days
@@ -501,6 +508,48 @@ struct IPGeolocation: Codable {
let longitude: Double?
}
+// MARK: - Ownership Models
+
+struct DomainOwnership: Codable, Equatable {
+ let registrar: String?
+ let createdDate: Date?
+ let expirationDate: Date?
+ let status: [String]
+ let nameservers: [String]
+ let abuseEmail: String?
+
+ init(
+ registrar: String? = nil,
+ createdDate: Date? = nil,
+ expirationDate: Date? = nil,
+ status: [String] = [],
+ nameservers: [String] = [],
+ abuseEmail: String? = nil
+ ) {
+ self.registrar = registrar
+ self.createdDate = createdDate
+ self.expirationDate = expirationDate
+ self.status = status
+ self.nameservers = nameservers
+ self.abuseEmail = abuseEmail
+ }
+
+ init(from decoder: Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ registrar = try container.decodeIfPresent(String.self, forKey: .registrar)
+ createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate)
+ expirationDate = try container.decodeIfPresent(Date.self, forKey: .expirationDate)
+ status = try container.decodeIfPresent([String].self, forKey: .status) ?? []
+ nameservers = try container.decodeIfPresent([String].self, forKey: .nameservers) ?? []
+ abuseEmail = try container.decodeIfPresent(String.self, forKey: .abuseEmail)
+ }
+}
+
+struct DiscoveredSubdomain: Codable, Equatable, Hashable, Identifiable {
+ var id: String { hostname }
+ let hostname: String
+}
+
// MARK: - Email Security Models
struct EmailSecurityResult: Codable {
@@ -627,8 +676,10 @@ struct HistoryEntry: Identifiable, Codable {
let ipGeolocation: IPGeolocation?
var emailSecurity: EmailSecurityResult?
var mtaSts: MTASTSResult?
+ var ownership: DomainOwnership?
var ptrRecord: String?
var redirectChain: [RedirectHop]
+ var subdomains: [DiscoveredSubdomain]
var portScanResults: [PortScanResult]
var hstsPreloaded: Bool?
var availabilityResult: DomainAvailabilityResult?
@@ -647,23 +698,26 @@ struct HistoryEntry: Identifiable, Codable {
var reachabilityError: String?
var ipGeolocationError: String?
var emailSecurityError: String?
+ var ownershipError: String?
var ptrError: String?
var redirectChainError: String?
+ var subdomainsError: String?
var portScanError: String?
init(domain: String, timestamp: Date, trackedDomainID: UUID? = nil, dnsSections: [DNSSection],
sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
- emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil,
- redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [],
+ emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ownership: DomainOwnership? = nil,
+ ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], subdomains: [DiscoveredSubdomain] = [],
+ portScanResults: [PortScanResult] = [],
hstsPreloaded: Bool? = nil, availabilityResult: DomainAvailabilityResult? = nil,
suggestions: [DomainSuggestionResult] = [], resolverDisplayName: String, resolverURLString: String,
totalLookupDurationMs: Int? = nil, primaryIP: String? = nil, finalRedirectURL: String? = nil,
tlsStatusSummary: String? = nil, emailSecuritySummary: String? = nil, httpGradeSummary: String? = nil,
changeSummary: DomainChangeSummary? = nil, sslError: String? = nil, httpHeadersError: String? = nil,
reachabilityError: String? = nil, ipGeolocationError: String? = nil,
- emailSecurityError: String? = nil, ptrError: String? = nil,
- redirectChainError: String? = nil, portScanError: String? = nil) {
+ emailSecurityError: String? = nil, ownershipError: String? = nil, ptrError: String? = nil,
+ redirectChainError: String? = nil, subdomainsError: String? = nil, portScanError: String? = nil) {
self.domain = domain
self.timestamp = timestamp
self.trackedDomainID = trackedDomainID
@@ -674,8 +728,10 @@ struct HistoryEntry: Identifiable, Codable {
self.ipGeolocation = ipGeolocation
self.emailSecurity = emailSecurity
self.mtaSts = mtaSts ?? emailSecurity?.mtaSts
+ self.ownership = ownership
self.ptrRecord = ptrRecord
self.redirectChain = redirectChain
+ self.subdomains = subdomains
self.portScanResults = portScanResults
self.hstsPreloaded = hstsPreloaded
self.availabilityResult = availabilityResult
@@ -694,8 +750,10 @@ struct HistoryEntry: Identifiable, Codable {
self.reachabilityError = reachabilityError
self.ipGeolocationError = ipGeolocationError
self.emailSecurityError = emailSecurityError
+ self.ownershipError = ownershipError
self.ptrError = ptrError
self.redirectChainError = redirectChainError
+ self.subdomainsError = subdomainsError
self.portScanError = portScanError
}
@@ -712,8 +770,10 @@ struct HistoryEntry: Identifiable, Codable {
ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
mtaSts = try container.decodeIfPresent(MTASTSResult.self, forKey: .mtaSts) ?? emailSecurity?.mtaSts
+ ownership = try container.decodeIfPresent(DomainOwnership.self, forKey: .ownership)
ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
+ subdomains = try container.decodeIfPresent([DiscoveredSubdomain].self, forKey: .subdomains) ?? []
portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded)
availabilityResult = try container.decodeIfPresent(DomainAvailabilityResult.self, forKey: .availabilityResult)
@@ -732,8 +792,10 @@ struct HistoryEntry: Identifiable, Codable {
reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError)
ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError)
emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError)
+ ownershipError = try container.decodeIfPresent(String.self, forKey: .ownershipError)
ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError)
redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError)
+ subdomainsError = try container.decodeIfPresent(String.self, forKey: .subdomainsError)
portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError)
}
}
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)
+ }
+}
diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift
new file mode 100644
index 0000000..57db25d
--- /dev/null
+++ b/DomainDig/SubdomainDiscoveryService.swift
@@ -0,0 +1,118 @@
+import Foundation
+
+enum SubdomainDiscoveryService {
+ static func discover(for domain: String, limit: Int = 25) async -> ServiceResult<[DiscoveredSubdomain]> {
+ let normalizedDomain = normalize(domain)
+ guard !normalizedDomain.isEmpty else {
+ return .empty("No passive subdomains found")
+ }
+
+ return await cache.subdomains(for: normalizedDomain, limit: limit)
+ }
+
+ private static let cache = SubdomainDiscoveryCache()
+
+ private static func normalize(_ domain: String) -> String {
+ domain
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .lowercased()
+ }
+}
+
+private actor SubdomainDiscoveryCache {
+ private var cachedResults: [String: ServiceResult<[DiscoveredSubdomain]>] = [:]
+ private var inFlightTasks: [String: Task<ServiceResult<[DiscoveredSubdomain]>, Never>] = [:]
+ private var lastRequestAt: Date?
+
+ func subdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
+ if let cachedResult = cachedResults[domain] {
+ return cachedResult
+ }
+
+ if let inFlightTask = inFlightTasks[domain] {
+ return await inFlightTask.value
+ }
+
+ let task = Task<ServiceResult<[DiscoveredSubdomain]>, Never> {
+ await enforceRateLimit()
+ return await fetchSubdomains(for: domain, limit: limit)
+ }
+ inFlightTasks[domain] = task
+
+ let result = await task.value
+ cachedResults[domain] = result
+ inFlightTasks[domain] = nil
+ return result
+ }
+
+ private func enforceRateLimit() async {
+ if let lastRequestAt {
+ let delay = max(0, 0.75 - Date().timeIntervalSince(lastRequestAt))
+ if delay > 0 {
+ try? await Task.sleep(for: .seconds(delay))
+ }
+ }
+ lastRequestAt = Date()
+ }
+
+ private func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
+ var components = URLComponents(string: "https://crt.sh/")!
+ components.queryItems = [
+ URLQueryItem(name: "q", value: "%.\(domain)"),
+ URLQueryItem(name: "output", value: "json")
+ ]
+
+ guard let url = components.url else {
+ return .error("Subdomain discovery unavailable")
+ }
+
+ do {
+ let request = URLRequest(url: url, timeoutInterval: 10)
+ let (data, response) = try await URLSession.shared.data(for: request)
+ guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
+ return .error("Subdomain discovery unavailable")
+ }
+
+ let entries = try JSONDecoder().decode([CRTShEntry].self, from: data)
+ let subdomains = parseSubdomains(from: entries, domain: domain, limit: limit)
+ return subdomains.isEmpty ? .empty("No passive subdomains found") : .success(subdomains)
+ } catch {
+ return .error(error.localizedDescription)
+ }
+ }
+
+ private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
+ var seen = Set<String>()
+ var results: [DiscoveredSubdomain] = []
+
+ for entry in entries {
+ let names = entry.nameValue
+ .split(separator: "\n")
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() }
+
+ for name in names {
+ let sanitized = name.hasPrefix("*.") ? String(name.dropFirst(2)) : name
+ guard sanitized != domain, sanitized.hasSuffix(".\(domain)") else {
+ continue
+ }
+ guard seen.insert(sanitized).inserted else {
+ continue
+ }
+ results.append(DiscoveredSubdomain(hostname: sanitized))
+ if results.count == limit {
+ return results
+ }
+ }
+ }
+
+ return results
+ }
+}
+
+private struct CRTShEntry: Decodable {
+ let nameValue: String
+
+ enum CodingKeys: String, CodingKey {
+ case nameValue = "name_value"
+ }
+}