summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-21 23:21:19 -0500
committerChristian Cleberg <[email protected]>2026-04-21 23:21:19 -0500
commit3846dc2f5dee69fc4ffbc052009a60e17d60e36b (patch)
tree867dca9d45b87f318c1b368cd535c9c9fb53e29d
parente73d58ee5dd43eaeaaa717f7781cc22256df30f0 (diff)
downloaddomain-dig-3846dc2f5dee69fc4ffbc052009a60e17d60e36b.tar.gz
domain-dig-3846dc2f5dee69fc4ffbc052009a60e17d60e36b.tar.bz2
domain-dig-3846dc2f5dee69fc4ffbc052009a60e17d60e36b.zip
feat(v2.5.0): add caching, request deduplication, and performance improvements
-rw-r--r--DomainDig.xcodeproj/project.pbxproj78
-rw-r--r--DomainDig/BatchResultsView.swift5
-rw-r--r--DomainDig/ContentView.swift50
-rw-r--r--DomainDig/DomainViewModel.swift267
-rw-r--r--DomainDig/LookupRuntime.swift289
-rw-r--r--DomainDig/Models.swift40
-rw-r--r--DomainDig/RDAPService.swift31
-rw-r--r--DomainDig/SubdomainDiscoveryService.swift45
-rw-r--r--DomainDigCLI.swift32
-rw-r--r--DomainInspectionService.swift285
-rw-r--r--DomainReportBuilder.swift2
-rw-r--r--DomainReportExporter.swift3
-rw-r--r--LookupSnapshot.swift12
13 files changed, 916 insertions, 223 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 40dadc9..fbaa03c 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -12,16 +12,20 @@
8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
+ 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
+ 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
+ 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
+ 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
- 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; };
8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; };
8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
+ 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -33,11 +37,9 @@
);
target = 8B7800682F6090E300933221 /* DomainDig */;
};
-/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
- Assets.xcassets,
BatchResultsView.swift,
BatchSweepSummaryView.swift,
ContentView.swift,
@@ -45,13 +47,13 @@
DomainViewModel.swift,
ExportPresenter.swift,
HistoryView.swift,
- Info.plist,
LocalNotificationService.swift,
SavedDomainsView.swift,
WatchlistView.swift,
);
target = 8BF124FB2F70000100933221 /* DomainDigCLI */;
};
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
8B78006B2F6090E300933221 /* DomainDig */ = {
@@ -132,7 +134,7 @@
};
8BF124FB2F70000100933221 /* DomainDigCLI */ = {
isa = PBXNativeTarget;
- buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */;
+ buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */;
buildPhases = (
8BF124FC2F70000100933221 /* Sources */,
8BF124FD2F70000100933221 /* Frameworks */,
@@ -201,7 +203,6 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
- 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
@@ -209,6 +210,18 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BF124FC2F70000100933221 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
+ 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */,
+ 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */,
+ 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */,
+ 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin XCBuildConfiguration section */
@@ -341,7 +354,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 17;
+ CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -358,7 +371,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.4.0;
+ MARKETING_VERSION = 2.5.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -377,7 +390,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 17;
+ CURRENT_PROJECT_VERSION = 18;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -394,7 +407,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.4.0;
+ MARKETING_VERSION = 2.5.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -407,44 +420,22 @@
};
name = Release;
};
- 8BF125002F70000100933221 /* Debug */ = {
+ 8BBFF0272F987E8700E8E144 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGNING_ALLOWED = NO;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 17;
- DEVELOPMENT_TEAM = ZCNAX3VL9D;
- ENABLE_TESTABILITY = YES;
- GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 2.4.0;
- PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
+ COPY_PHASE_STRIP = NO;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_OPTIMIZATION_LEVEL = 0;
PRODUCT_NAME = domaindig;
- SDKROOT = macosx;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
};
name = Debug;
};
- 8BF125012F70000100933221 /* Release */ = {
+ 8BBFF0282F987E8700E8E144 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
- CODE_SIGNING_ALLOWED = NO;
- CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 17;
- DEVELOPMENT_TEAM = ZCNAX3VL9D;
- GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 14.0;
- MARKETING_VERSION = 2.4.0;
- PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
+ COPY_PHASE_STRIP = YES;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
PRODUCT_NAME = domaindig;
- SDKROOT = macosx;
- SWIFT_APPROACHABLE_CONCURRENCY = YES;
- SWIFT_EMIT_LOC_STRINGS = NO;
- SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
- SWIFT_VERSION = 5.0;
};
name = Release;
};
@@ -469,6 +460,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 8BBFF0292F987EF700E8E144 /* Build configuration list */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 8BBFF0272F987E8700E8E144 /* Debug */,
+ 8BBFF0282F987E8700E8E144 /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 8B7800612F6090E300933221 /* Project object */;
diff --git a/DomainDig/BatchResultsView.swift b/DomainDig/BatchResultsView.swift
index 6bf18c0..1e0333d 100644
--- a/DomainDig/BatchResultsView.swift
+++ b/DomainDig/BatchResultsView.swift
@@ -58,6 +58,9 @@ struct BatchResultRowView: View {
.foregroundStyle(.primary)
.lineLimit(1)
Spacer(minLength: 8)
+ Text(result.resultSource.label.lowercased())
+ .font(.system(.caption2, design: .monospaced))
+ .foregroundStyle(.secondary)
Text(result.quickStatus)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(quickStatusColor)
@@ -84,7 +87,7 @@ struct BatchResultRowView: View {
if let errorMessage = result.errorMessage {
Text(errorMessage)
.font(.system(.caption2, design: .monospaced))
- .foregroundStyle(.red)
+ .foregroundStyle(result.status == .failed ? .red : .secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index c5fc1fa..7a3a42d 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -30,6 +30,10 @@ struct ContentView: View {
}
if viewModel.hasRun {
actionButtons
+ if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) {
+ LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
+ .padding(.top, 8)
+ }
SummaryView(fields: viewModel.summaryFields)
.padding(.top, 8)
if let changeSummary = viewModel.currentChangeSummary {
@@ -494,6 +498,52 @@ struct SummaryView: View {
}
}
+struct LookupStatusBannerView: View {
+ let message: String
+ let resultSource: LookupResultSource
+
+ var body: some View {
+ HStack(spacing: 8) {
+ Image(systemName: iconName)
+ .font(.caption)
+ Text(message)
+ .font(.system(.caption, design: .monospaced))
+ Spacer()
+ }
+ .foregroundStyle(color)
+ .padding(8)
+ .frame(maxWidth: .infinity, alignment: .leading)
+ .background(color.opacity(0.12))
+ .clipShape(RoundedRectangle(cornerRadius: 8))
+ }
+
+ private var color: Color {
+ switch resultSource {
+ case .live:
+ return .green
+ case .cached:
+ return .secondary
+ case .mixed:
+ return .yellow
+ case .snapshot:
+ return .orange
+ }
+ }
+
+ private var iconName: String {
+ switch resultSource {
+ case .live:
+ return "bolt.horizontal"
+ case .cached:
+ return "clock.arrow.trianglehead.counterclockwise.rotate.90"
+ case .mixed:
+ return "arrow.triangle.branch"
+ case .snapshot:
+ return "archivebox"
+ }
+ }
+}
+
struct DomainChangeSummaryView: View {
let summary: DomainChangeSummary
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 71209dc..9fdf1fc 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -184,6 +184,12 @@ final class DomainViewModel {
private var activeBatchDomains: [String] = []
private var lastBatchStartedAt: Date?
private let reportBuilder = DomainReportBuilder()
+ private let inspectionService = DomainInspectionService()
+ private(set) var currentResultSource: LookupResultSource = .live
+ private(set) var currentCachedSections: [LookupSectionKind] = []
+ private(set) var currentStatusMessage: String?
+ private(set) var currentSnapshotTimestamp = Date()
+ private(set) var currentHistoryEntryID: UUID?
private static let recentSearchesKey = "recentSearches"
private static let maxRecent = 20
@@ -355,9 +361,9 @@ final class DomainViewModel {
var currentSnapshot: LookupSnapshot {
LookupSnapshot(
- historyEntryID: nil,
+ historyEntryID: currentHistoryEntryID,
domain: searchedDomain,
- timestamp: Date(),
+ timestamp: currentSnapshotTimestamp,
trackedDomainID: currentTrackedDomain?.id,
resolverDisplayName: resolverDisplayName,
resolverURLString: resolverURLString,
@@ -393,7 +399,9 @@ final class DomainViewModel {
portScanResults: allPortScanResults,
portScanError: combinedPortScanError,
changeSummary: currentChangeSummary,
- isLive: true
+ resultSource: currentResultSource,
+ cachedSections: currentCachedSections,
+ statusMessage: currentStatusMessage
)
}
@@ -746,51 +754,163 @@ final class DomainViewModel {
}
private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
- await withTaskGroup(of: Void.self) { group in
- group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
- 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) }
- }
+ let previous = previousSnapshot(
+ for: domain,
+ trackedDomainID: currentTrackedDomain?.id,
+ replacingLatest: false
+ )
+ let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
+ guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
- await withTaskGroup(of: Void.self) { group in
- group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
- group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
- group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
- group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
+ let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
+ applySnapshot(snapshot)
+ lastLookupDurationMs = snapshot.totalLookupDurationMs
+ refreshingTrackedDomainID = nil
+
+ guard snapshot.statusMessage == nil else {
+ return history.first(where: { $0.id == snapshot.historyEntryID })
}
- guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
+ return saveHistoryEntry(replaceLatest: false)
+ }
- let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
- let primaryIP = primaryIPAddress(from: dnsSections)
+ private func applySnapshot(_ snapshot: LookupSnapshot) {
+ currentHistoryEntryID = snapshot.historyEntryID
+ currentSnapshotTimestamp = snapshot.timestamp
+ currentResultSource = snapshot.resultSource
+ currentCachedSections = snapshot.cachedSections
+ currentStatusMessage = snapshot.statusMessage
+ currentChangeSummary = snapshot.changeSummary
+ currentDiffSections = []
+ ownershipDiff = []
- await withTaskGroup(of: Void.self) { group in
- group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) }
- if let primaryIP {
- group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) }
- group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) }
- } else {
- group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) }
- }
- }
+ dnsSections = snapshot.dnsSections
+ dnsError = snapshot.dnsError
+ availabilityResult = snapshot.availabilityResult
+ suggestions = snapshot.suggestions
+ sslInfo = snapshot.sslInfo
+ sslError = snapshot.sslError
+ hstsPreloaded = snapshot.hstsPreloaded
+ httpHeaders = snapshot.httpHeaders
+ httpSecurityGrade = snapshot.httpSecurityGrade
+ httpStatusCode = snapshot.httpStatusCode
+ httpResponseTimeMs = snapshot.httpResponseTimeMs
+ httpProtocol = snapshot.httpProtocol
+ http3Advertised = snapshot.http3Advertised
+ httpHeadersError = snapshot.httpHeadersError
+ reachabilityResults = snapshot.reachabilityResults
+ reachabilityError = snapshot.reachabilityError
+ ipGeolocation = snapshot.ipGeolocation
+ ipGeolocationError = snapshot.ipGeolocationError
+ emailSecurity = snapshot.emailSecurity
+ emailSecurityError = snapshot.emailSecurityError
+ ownershipResult = snapshot.ownership
+ ownershipError = snapshot.ownershipError
+ ptrRecord = snapshot.ptrRecord
+ ptrError = snapshot.ptrError
+ redirectChain = snapshot.redirectChain
+ redirectChainError = snapshot.redirectChainError
+ subdomains = snapshot.subdomains
+ subdomainsError = snapshot.subdomainsError
+ portScanResults = snapshot.portScanResults.filter { $0.kind == .standard }
+ customPortResults = snapshot.portScanResults.filter { $0.kind == .custom }
+ portScanError = snapshot.portScanError
+ customPortScanError = nil
- guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
+ dnsLoading = false
+ availabilityLoading = false
+ suggestionsLoading = false
+ sslLoading = false
+ hstsLoading = false
+ httpHeadersLoading = false
+ reachabilityLoading = false
+ ipGeolocationLoading = false
+ emailSecurityLoading = false
+ ownershipLoading = false
+ ptrLoading = false
+ redirectChainLoading = false
+ subdomainsLoading = false
+ portScanLoading = false
+ customPortScanLoading = false
+ }
- if availabilityResult?.status == .registered {
- await runSuggestions(domain: domain, lookupID: lookupID)
- } else {
- suggestions = []
- suggestionsLoading = false
+ private static func resolvedSnapshotAfterFallback(
+ _ snapshot: LookupSnapshot,
+ previousSnapshot: LookupSnapshot?
+ ) -> LookupSnapshot {
+ guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
+ return snapshot
+ }
+
+ return LookupSnapshot(
+ historyEntryID: previousSnapshot.historyEntryID,
+ domain: previousSnapshot.domain,
+ timestamp: previousSnapshot.timestamp,
+ trackedDomainID: previousSnapshot.trackedDomainID,
+ resolverDisplayName: previousSnapshot.resolverDisplayName,
+ resolverURLString: previousSnapshot.resolverURLString,
+ totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
+ dnsSections: previousSnapshot.dnsSections,
+ dnsError: previousSnapshot.dnsError,
+ availabilityResult: previousSnapshot.availabilityResult,
+ suggestions: previousSnapshot.suggestions,
+ sslInfo: previousSnapshot.sslInfo,
+ sslError: previousSnapshot.sslError,
+ hstsPreloaded: previousSnapshot.hstsPreloaded,
+ httpHeaders: previousSnapshot.httpHeaders,
+ httpSecurityGrade: previousSnapshot.httpSecurityGrade,
+ httpStatusCode: previousSnapshot.httpStatusCode,
+ httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
+ httpProtocol: previousSnapshot.httpProtocol,
+ http3Advertised: previousSnapshot.http3Advertised,
+ httpHeadersError: previousSnapshot.httpHeadersError,
+ reachabilityResults: previousSnapshot.reachabilityResults,
+ reachabilityError: previousSnapshot.reachabilityError,
+ ipGeolocation: previousSnapshot.ipGeolocation,
+ ipGeolocationError: previousSnapshot.ipGeolocationError,
+ emailSecurity: previousSnapshot.emailSecurity,
+ emailSecurityError: previousSnapshot.emailSecurityError,
+ ownership: previousSnapshot.ownership,
+ ownershipError: previousSnapshot.ownershipError,
+ ptrRecord: previousSnapshot.ptrRecord,
+ ptrError: previousSnapshot.ptrError,
+ redirectChain: previousSnapshot.redirectChain,
+ redirectChainError: previousSnapshot.redirectChainError,
+ subdomains: previousSnapshot.subdomains,
+ subdomainsError: previousSnapshot.subdomainsError,
+ portScanResults: previousSnapshot.portScanResults,
+ portScanError: previousSnapshot.portScanError,
+ changeSummary: previousSnapshot.changeSummary,
+ resultSource: .snapshot,
+ cachedSections: [],
+ statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
+ )
+ }
+
+ private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
+ let candidateMessages = [
+ snapshot.dnsError,
+ snapshot.httpHeadersError,
+ snapshot.sslError,
+ snapshot.ownershipError,
+ snapshot.subdomainsError,
+ snapshot.redirectChainError,
+ snapshot.ipGeolocationError
+ ]
+ .compactMap { $0?.lowercased() }
+
+ guard !candidateMessages.isEmpty else { return false }
+ let failedDueToConnectivity = candidateMessages.allSatisfy { message in
+ message.hasPrefix("network error:") || message.hasPrefix("timeout:") || message.hasPrefix("rate limit:")
}
- guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
- lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
- let entry = saveHistoryEntry(replaceLatest: false)
- refreshingTrackedDomainID = nil
- return entry
+ let hasMaterialData = !snapshot.dnsSections.isEmpty
+ || !snapshot.httpHeaders.isEmpty
+ || snapshot.sslInfo != nil
+ || snapshot.ownership != nil
+ || !snapshot.subdomains.isEmpty
+
+ return failedDueToConnectivity && !hasMaterialData
}
private func runDNS(domain: String, lookupID: UUID) async {
@@ -1044,11 +1164,12 @@ final class DomainViewModel {
customPortScanLoading = false
}
- private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
+ private static func performBatchLookup(domain: String, previousSnapshot: LookupSnapshot?) async -> BatchLookupPayload? {
guard !Task.isCancelled else { return nil }
- let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain)
+ let inspectionService = DomainInspectionService()
+ let snapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
guard !Task.isCancelled else { return nil }
- return BatchLookupPayload(snapshot: snapshot)
+ return BatchLookupPayload(snapshot: resolvedSnapshotAfterFallback(snapshot, previousSnapshot: previousSnapshot))
}
private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
@@ -1141,6 +1262,10 @@ final class DomainViewModel {
portScanError: snapshot.portScanError
)
+ if updateCurrentState {
+ currentHistoryEntryID = entry.id
+ }
+
if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
history[0] = entry
} else {
@@ -1318,6 +1443,11 @@ final class DomainViewModel {
addRecentSearch(target)
searchedDomain = target
hasRun = true
+ currentHistoryEntryID = nil
+ currentSnapshotTimestamp = Date()
+ currentResultSource = .live
+ currentCachedSections = []
+ currentStatusMessage = nil
currentDiffSections = []
currentChangeSummary = nil
ownershipDiff = []
@@ -1424,9 +1554,10 @@ final class DomainViewModel {
refreshingTrackedDomainID = trackedDomain(for: domain)?.id
}
updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
+ let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false)
- group.addTask { [domain] in
- let payload = await Self.performBatchLookup(domain: domain)
+ group.addTask { [domain, previousSnapshot] in
+ let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot)
return (domain, payload)
}
}
@@ -1441,13 +1572,21 @@ final class DomainViewModel {
status: .failed,
quickStatus: "Failed",
entry: nil,
+ resultSource: .live,
errorMessage: "Lookup cancelled"
)
batchCompletedCount += 1
return
}
- let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
+ let entry: HistoryEntry?
+ if payload.snapshot.statusMessage == nil {
+ entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
+ } else {
+ entry = payload.snapshot.historyEntryID.flatMap { id in
+ history.first(where: { $0.id == id })
+ }
+ }
let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
let quickStatus: String
if entry?.changeSummary?.hasChanges == true {
@@ -1463,7 +1602,8 @@ final class DomainViewModel {
status: .completed,
quickStatus: quickStatus,
entry: entry,
- errorMessage: nil
+ resultSource: payload.snapshot.resultSource,
+ errorMessage: payload.snapshot.statusMessage
)
batchCompletedCount += 1
}
@@ -1503,6 +1643,7 @@ final class DomainViewModel {
status: BatchLookupStatus,
quickStatus: String,
entry: HistoryEntry?,
+ resultSource: LookupResultSource = .live,
errorMessage: String?
) {
guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
@@ -1513,6 +1654,7 @@ final class DomainViewModel {
id: batchResults[index].id,
domain: domain,
historyEntryID: entry?.id,
+ resultSource: resultSource,
availability: entry?.availabilityResult?.status,
primaryIP: entry?.primaryIP,
quickStatus: quickStatus,
@@ -1573,6 +1715,11 @@ final class DomainViewModel {
customPortResults = []
customPortScanError = nil
customPortScanLoading = false
+ currentHistoryEntryID = nil
+ currentSnapshotTimestamp = Date()
+ currentResultSource = .live
+ currentCachedSections = []
+ currentStatusMessage = nil
}
private func setAllLoadingStates(_ loading: Bool) {
@@ -1715,7 +1862,9 @@ final class DomainViewModel {
portScanResults: [],
portScanError: nil,
changeSummary: trackedDomain.lastChangeSummary,
- isLive: false
+ resultSource: .snapshot,
+ cachedSections: [],
+ statusMessage: nil
)
}
@@ -1801,7 +1950,8 @@ final class DomainViewModel {
SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
- SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary)
+ SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
+ SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot))
]
}
@@ -1809,7 +1959,7 @@ final class DomainViewModel {
var rows = [
InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
- InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning),
+ InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)),
InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
]
rows.insert(
@@ -2083,7 +2233,7 @@ final class DomainViewModel {
"DomainDig Export",
"Domain: \(snapshot.domain)",
"Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
- "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
+ "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)",
"Resolver: \(snapshot.resolverDisplayName)",
"Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
"Tracked: \(trackedDomain == nil ? "No" : "Yes")"
@@ -2417,6 +2567,23 @@ final class DomainViewModel {
}
}
+ private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone {
+ if snapshot.statusMessage != nil {
+ return .warning
+ }
+
+ switch snapshot.resultSource {
+ case .live:
+ return .success
+ case .cached:
+ return .secondary
+ case .mixed:
+ return .warning
+ case .snapshot:
+ return .warning
+ }
+ }
+
private static func securityGradeTone(_ grade: String) -> ResultTone {
switch grade {
case "A", "B":
diff --git a/DomainDig/LookupRuntime.swift b/DomainDig/LookupRuntime.swift
new file mode 100644
index 0000000..b70a134
--- /dev/null
+++ b/DomainDig/LookupRuntime.swift
@@ -0,0 +1,289 @@
+import Foundation
+
+struct CachedLookupResult<Value> {
+ let value: Value
+ let source: LookupResultSource
+}
+
+actor LookupRuntime {
+ static let shared = LookupRuntime()
+
+ private let ttl: TimeInterval = 300
+
+ private enum RequestKey: Hashable {
+ case domain(String, LookupSectionKind)
+ case subject(String, LookupSectionKind)
+ }
+
+ private enum RateLimitBucket: Hashable {
+ case crtsh
+ case rdap
+ case ipGeolocation
+
+ var minimumSpacing: TimeInterval {
+ switch self {
+ case .crtsh:
+ return 1.0
+ case .rdap:
+ return 0.75
+ case .ipGeolocation:
+ return 0.75
+ }
+ }
+ }
+
+ private enum CachedPayload {
+ case dns(ServiceResult<[DNSSection]>)
+ case availability(DomainAvailabilityResult)
+ case ssl(ServiceResult<SSLCertificateInfo>)
+ case hsts(Bool?)
+ case http(ServiceResult<HTTPHeadersResult>)
+ case reachability(ServiceResult<[PortReachability]>)
+ case ownership(ServiceResult<DomainOwnership>)
+ case redirect(ServiceResult<[RedirectHop]>)
+ case subdomains(ServiceResult<[DiscoveredSubdomain]>)
+ case portScan(ServiceResult<[PortScanResult]>)
+ case email(ServiceResult<EmailSecurityResult>)
+ case ptr(ServiceResult<String>)
+ case ipGeolocation(ServiceResult<IPGeolocation>)
+ case suggestions([DomainSuggestionResult])
+ }
+
+ private struct CacheEntry {
+ let payload: CachedPayload
+ let expiresAt: Date
+ }
+
+ private var cache: [RequestKey: CacheEntry] = [:]
+ private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
+ private var nextAllowedAt: [RateLimitBucket: Date] = [:]
+
+ func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> {
+ await execute(
+ key: .domain(domain, .dns),
+ extract: { payload in
+ guard case let .dns(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .dns(await DNSLookupService.lookupAll(domain: domain))
+ }
+ )
+ }
+
+ func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> {
+ await execute(
+ key: .domain(domain, .availability),
+ extract: { payload in
+ guard case let .availability(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .availability(await DomainAvailabilityService.check(domain: domain))
+ }
+ )
+ }
+
+ func ssl(domain: String) async -> CachedLookupResult<ServiceResult<SSLCertificateInfo>> {
+ await execute(
+ key: .domain(domain, .ssl),
+ extract: { payload in
+ guard case let .ssl(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .ssl(await SSLCheckService.check(domain: domain))
+ }
+ )
+ }
+
+ func hsts(domain: String) async -> CachedLookupResult<Bool?> {
+ await execute(
+ key: .domain(domain, .hsts),
+ extract: { payload in
+ guard case let .hsts(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .hsts(await SSLCheckService.checkHSTSPreload(domain: domain))
+ }
+ )
+ }
+
+ func http(domain: String) async -> CachedLookupResult<ServiceResult<HTTPHeadersResult>> {
+ await execute(
+ key: .domain(domain, .httpHeaders),
+ extract: { payload in
+ guard case let .http(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .http(await HTTPHeadersService.fetch(domain: domain))
+ }
+ )
+ }
+
+ func reachability(domain: String) async -> CachedLookupResult<ServiceResult<[PortReachability]>> {
+ await execute(
+ key: .domain(domain, .reachability),
+ extract: { payload in
+ guard case let .reachability(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .reachability(await ReachabilityService.checkAll(domain: domain))
+ }
+ )
+ }
+
+ func ownership(domain: String) async -> CachedLookupResult<ServiceResult<DomainOwnership>> {
+ await execute(
+ key: .domain(domain, .ownership),
+ rateLimitBucket: .rdap,
+ extract: { payload in
+ guard case let .ownership(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .ownership(await DomainOwnershipService.lookup(domain: domain))
+ }
+ )
+ }
+
+ func redirectChain(domain: String) async -> CachedLookupResult<ServiceResult<[RedirectHop]>> {
+ await execute(
+ key: .domain(domain, .redirectChain),
+ extract: { payload in
+ guard case let .redirect(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .redirect(await RedirectChainService.trace(domain: domain))
+ }
+ )
+ }
+
+ func subdomains(domain: String) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> {
+ await execute(
+ key: .domain(domain, .subdomains),
+ rateLimitBucket: .crtsh,
+ extract: { payload in
+ guard case let .subdomains(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .subdomains(await SubdomainDiscoveryService.discover(for: domain))
+ }
+ )
+ }
+
+ func portScan(domain: String) async -> CachedLookupResult<ServiceResult<[PortScanResult]>> {
+ await execute(
+ key: .domain(domain, .portScan),
+ extract: { payload in
+ guard case let .portScan(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .portScan(await PortScanService.scanAll(domain: domain))
+ }
+ )
+ }
+
+ func email(domain: String, txtRecords: [DNSRecord]) async -> CachedLookupResult<ServiceResult<EmailSecurityResult>> {
+ await execute(
+ key: .domain(domain, .emailSecurity),
+ extract: { payload in
+ guard case let .email(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .email(await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords))
+ }
+ )
+ }
+
+ func ptr(ip: String, resolverURLString: String) async -> CachedLookupResult<ServiceResult<String>> {
+ await execute(
+ key: .subject("\(resolverURLString)|\(ip)", .ptr),
+ extract: { payload in
+ guard case let .ptr(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .ptr(await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString))
+ }
+ )
+ }
+
+ func ipGeolocation(ip: String) async -> CachedLookupResult<ServiceResult<IPGeolocation>> {
+ await execute(
+ key: .subject(ip, .ipGeolocation),
+ rateLimitBucket: .ipGeolocation,
+ extract: { payload in
+ guard case let .ipGeolocation(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .ipGeolocation(await IPGeolocationService.lookup(ip: ip))
+ }
+ )
+ }
+
+ func suggestions(domain: String) async -> CachedLookupResult<[DomainSuggestionResult]> {
+ await execute(
+ key: .domain(domain, .suggestions),
+ extract: { payload in
+ guard case let .suggestions(result) = payload else { return nil }
+ return result
+ },
+ operation: {
+ .suggestions(await DomainAvailabilityService.suggestions(for: domain))
+ }
+ )
+ }
+
+ private func execute<T>(
+ key: RequestKey,
+ rateLimitBucket: RateLimitBucket? = nil,
+ extract: @escaping (CachedPayload) -> T?,
+ operation: @escaping @Sendable () async -> CachedPayload
+ ) async -> CachedLookupResult<T> {
+ if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) {
+ return CachedLookupResult(value: value, source: .cached)
+ }
+
+ if let task = inFlight[key], let value = extract(await task.value) {
+ return CachedLookupResult(value: value, source: .mixed)
+ }
+
+ let task = Task<CachedPayload, Never> {
+ if let rateLimitBucket {
+ await self.enforceRateLimit(for: rateLimitBucket)
+ }
+ return await operation()
+ }
+ inFlight[key] = task
+
+ let payload = await task.value
+ cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl))
+ inFlight[key] = nil
+
+ guard let value = extract(payload) else {
+ fatalError("LookupRuntime payload extraction mismatch")
+ }
+
+ return CachedLookupResult(value: value, source: .live)
+ }
+
+ private func enforceRateLimit(for bucket: RateLimitBucket) async {
+ let now = Date()
+ if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now {
+ let delay = nextAllowed.timeIntervalSince(now)
+ if delay > 0 {
+ try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
+ }
+ }
+ nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing)
+ }
+}
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index b1e5432..85136d2 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -6,6 +6,43 @@ enum ServiceResult<Value> {
case error(String)
}
+enum LookupResultSource: String, Codable {
+ case live
+ case cached
+ case mixed
+ case snapshot
+
+ var label: String {
+ switch self {
+ case .live:
+ return "Live"
+ case .cached:
+ return "Cached"
+ case .mixed:
+ return "Mixed"
+ case .snapshot:
+ return "Snapshot"
+ }
+ }
+}
+
+enum LookupSectionKind: String, Codable, CaseIterable {
+ case dns
+ case availability
+ case ssl
+ case hsts
+ case httpHeaders
+ case reachability
+ case ipGeolocation
+ case emailSecurity
+ case ownership
+ case ptr
+ case redirectChain
+ case subdomains
+ case portScan
+ case suggestions
+}
+
enum DomainAvailabilityStatus: String, Codable {
case available
case registered
@@ -134,6 +171,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
let id: UUID
let domain: String
let historyEntryID: UUID?
+ let resultSource: LookupResultSource
let availability: DomainAvailabilityStatus?
let primaryIP: String?
let quickStatus: String
@@ -148,6 +186,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
id: UUID = UUID(),
domain: String,
historyEntryID: UUID?,
+ resultSource: LookupResultSource = .live,
availability: DomainAvailabilityStatus?,
primaryIP: String?,
quickStatus: String,
@@ -161,6 +200,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
self.id = id
self.domain = domain
self.historyEntryID = historyEntryID
+ self.resultSource = resultSource
self.availability = availability
self.primaryIP = primaryIP
self.quickStatus = quickStatus
diff --git a/DomainDig/RDAPService.swift b/DomainDig/RDAPService.swift
index c38a140..3556624 100644
--- a/DomainDig/RDAPService.swift
+++ b/DomainDig/RDAPService.swift
@@ -5,7 +5,7 @@ enum RDAPService {
let normalizedDomain = normalize(domain)
guard !normalizedDomain.isEmpty else { return nil }
- switch await cache.response(for: normalizedDomain) {
+ switch await fetchRDAPResponse(for: normalizedDomain) {
case let .success(response):
return response.isDomainRecord ? .registered : nil
case .empty:
@@ -21,7 +21,7 @@ enum RDAPService {
return .empty("Unavailable")
}
- switch await cache.response(for: normalizedDomain) {
+ switch await fetchRDAPResponse(for: normalizedDomain) {
case let .success(response):
let ownership = DomainOwnership(
registrar: response.registrarName,
@@ -39,8 +39,6 @@ enum RDAPService {
}
}
- private static let cache = RDAPCache()
-
private static func normalize(_ domain: String) -> String {
domain
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -48,31 +46,6 @@ enum RDAPService {
}
}
-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")
diff --git a/DomainDig/SubdomainDiscoveryService.swift b/DomainDig/SubdomainDiscoveryService.swift
index 57db25d..4e25cf0 100644
--- a/DomainDig/SubdomainDiscoveryService.swift
+++ b/DomainDig/SubdomainDiscoveryService.swift
@@ -7,55 +7,16 @@ enum SubdomainDiscoveryService {
return .empty("No passive subdomains found")
}
- return await cache.subdomains(for: normalizedDomain, limit: limit)
+ return await fetchSubdomains(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]> {
+ private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
var components = URLComponents(string: "https://crt.sh/")!
components.queryItems = [
URLQueryItem(name: "q", value: "%.\(domain)"),
@@ -81,7 +42,7 @@ private actor SubdomainDiscoveryCache {
}
}
- private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
+ private static func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
var seen = Set<String>()
var results: [DiscoveredSubdomain] = []
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
index 73f7028..d0a4d86 100644
--- a/DomainDigCLI.swift
+++ b/DomainDigCLI.swift
@@ -14,19 +14,39 @@ struct DomainDigCLI {
let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
let domains = arguments.filter { !$0.hasPrefix("-") }
- guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ let requestedDomains = domains
+ .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+
+ guard !requestedDomains.isEmpty else {
fputs("usage: domaindig <domain> [--json]\n", stderr)
Foundation.exit(1)
}
let inspectionService = DomainInspectionService()
- let report = await inspectionService.inspect(domain: domain)
+ var reports: [DomainReport] = []
+ var seen = Set<String>()
+
+ for domain in requestedDomains {
+ let normalizedDomain = domain.lowercased()
+ guard seen.insert(normalizedDomain).inserted else { continue }
+ reports.append(await inspectionService.inspect(domain: domain))
+ }
do {
- let data = try DomainReportExporter.data(
- for: report,
- format: wantsJSON ? .json : .text
- )
+ let data: Data
+ if reports.count == 1, let report = reports.first {
+ data = try DomainReportExporter.data(
+ for: report,
+ format: wantsJSON ? .json : .text
+ )
+ } else {
+ data = try DomainReportExporter.data(
+ for: reports,
+ format: wantsJSON ? .json : .text,
+ title: "DomainDig Batch Report"
+ )
+ }
FileHandle.standardOutput.write(data)
if data.last != 0x0A {
FileHandle.standardOutput.write(Data([0x0A]))
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
index 3f11290..cb9350b 100644
--- a/DomainInspectionService.swift
+++ b/DomainInspectionService.swift
@@ -2,89 +2,155 @@ import Foundation
struct DomainInspectionService {
private let reportBuilder = DomainReportBuilder()
+ private let runtime: LookupRuntime
- func inspect(domain: String) async -> DomainReport {
- let snapshot = await inspectSnapshot(domain: domain)
+ init(runtime: LookupRuntime = .shared) {
+ self.runtime = runtime
+ }
+
+ func inspect(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> DomainReport {
+ let snapshot = await inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
return reportBuilder.build(from: snapshot)
}
- func inspectSnapshot(domain: String) async -> LookupSnapshot {
+ func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot {
let normalizedDomain = normalize(domain)
let startedAt = Date()
let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
let resolverURLString = DNSLookupService.currentResolverURLString()
+ var cachedSections = Set<LookupSectionKind>()
+ var sectionSources: [LookupResultSource] = []
+
+ async let dnsFetch = runtime.dns(domain: normalizedDomain)
+ async let availabilityFetch = runtime.availability(domain: normalizedDomain)
+ async let sslFetch = runtime.ssl(domain: normalizedDomain)
+ async let hstsFetch = runtime.hsts(domain: normalizedDomain)
+ async let httpFetch = runtime.http(domain: normalizedDomain)
+ async let reachabilityFetch = runtime.reachability(domain: normalizedDomain)
+ async let ownershipFetch = runtime.ownership(domain: normalizedDomain)
+ async let redirectFetch = runtime.redirectChain(domain: normalizedDomain)
+ async let subdomainFetch = runtime.subdomains(domain: normalizedDomain)
+ async let portScanFetch = runtime.portScan(domain: normalizedDomain)
+
+ let resolvedDNS = await dnsFetch
+ let dnsResult = normalizeErrors(in: resolvedDNS.value)
+ track(.dns, source: resolvedDNS.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let availability = await availabilityFetch
+ track(.availability, source: availability.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let resolvedSSL = await sslFetch
+ let sslResult = normalizeErrors(in: resolvedSSL.value)
+ track(.ssl, source: resolvedSSL.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let hsts = await hstsFetch
+ track(.hsts, source: hsts.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
- async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain)
- async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain)
- async let sslResult = SSLCheckService.check(domain: normalizedDomain)
- async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain)
- async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain)
- async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain)
- async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain)
- async let redirectResult = RedirectChainService.trace(domain: normalizedDomain)
- async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain)
- async let portScanResult = PortScanService.scanAll(domain: normalizedDomain)
-
- let resolvedDNS = await dnsResult
- let availability = await availabilityResult
- let resolvedSSL = await sslResult
- 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
-
- let dnsSections = mapServiceResult(resolvedDNS, emptyValue: [])
- let sslInfo = mapOptionalValueServiceResult(resolvedSSL)
- let httpHeadersResult = mapHTTPResult(http)
- let reachabilityResultValue = mapServiceResult(reachability, emptyValue: [])
- let redirectChain = mapServiceResult(redirects, emptyValue: [])
- let ownership = mapOptionalValueServiceResult(resolvedOwnership)
- let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: [])
- let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain)
+ let http = await httpFetch
+ let httpResult = normalizeErrors(in: http.value)
+ track(.httpHeaders, source: http.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let reachability = await reachabilityFetch
+ let reachabilityResult = normalizeErrors(in: reachability.value)
+ track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let resolvedOwnership = await ownershipFetch
+ let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
+ track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let redirects = await redirectFetch
+ let redirectResult = normalizeErrors(in: redirects.value)
+ track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let resolvedSubdomains = await subdomainFetch
+ let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
+ track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let ports = await portScanFetch
+ let portScanResult = normalizeErrors(in: ports.value)
+ track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+
+ let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
+ let sslInfo = mapOptionalValueServiceResult(sslResult)
+ let httpHeadersResult = mapHTTPResult(httpResult)
+ let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
+ let redirectChain = mapServiceResult(redirectResult, emptyValue: [])
+ let ownership = mapOptionalValueServiceResult(ownershipResult)
+ let subdomains = mapServiceResult(subdomainResult, emptyValue: [])
+ let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain)
let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
+ let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value)
+ let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP)
- async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords)
- async let suggestions = availability.status == .registered
- ? DomainAvailabilityService.suggestions(for: normalizedDomain)
- : []
+ let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>>
+ if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity {
+ emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached)
+ } else if canReuseDNSDependents, let previousSnapshot, let error = previousSnapshot.emailSecurityError {
+ emailOutcome = CachedLookupResult(value: .error(error), source: .cached)
+ } else {
+ emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords)
+ }
+ track(.emailSecurity, source: emailOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
- let resolvedEmail = await emailResult
- let resolvedSuggestions = await suggestions
+ let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]>
+ if availability.value.status == .registered {
+ suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain)
+ track(.suggestions, source: suggestionsOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+ } else {
+ suggestionsOutcome = CachedLookupResult(value: [], source: .live)
+ }
- let ptrResult: ServiceResult<String>?
- let geoResult: ServiceResult<IPGeolocation>?
+ let ptrOutcome: CachedLookupResult<ServiceResult<String>>?
+ let geoOutcome: CachedLookupResult<ServiceResult<IPGeolocation>>?
if let primaryIP {
- ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
- geoResult = await IPGeolocationService.lookup(ip: primaryIP)
+ if canReuseIPDependents, let previousSnapshot, let ptrRecord = previousSnapshot.ptrRecord {
+ ptrOutcome = CachedLookupResult(value: .success(ptrRecord), source: .cached)
+ } else if canReuseIPDependents, let previousSnapshot, let ptrError = previousSnapshot.ptrError {
+ ptrOutcome = CachedLookupResult(value: .error(ptrError), source: .cached)
+ } else {
+ ptrOutcome = await runtime.ptr(ip: primaryIP, resolverURLString: resolverURLString)
+ }
+
+ if canReuseIPDependents, let previousSnapshot, let ipGeolocation = previousSnapshot.ipGeolocation {
+ geoOutcome = CachedLookupResult(value: .success(ipGeolocation), source: .cached)
+ } else if canReuseIPDependents, let previousSnapshot, let ipGeolocationError = previousSnapshot.ipGeolocationError {
+ geoOutcome = CachedLookupResult(value: .error(ipGeolocationError), source: .cached)
+ } else {
+ geoOutcome = await runtime.ipGeolocation(ip: primaryIP)
+ }
+
+ if let ptrOutcome {
+ track(.ptr, source: ptrOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+ }
+ if let geoOutcome {
+ track(.ipGeolocation, source: geoOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
+ }
} else {
- ptrResult = nil
- geoResult = nil
+ ptrOutcome = nil
+ geoOutcome = nil
}
- let emailSecurity = mapOptionalValueServiceResult(resolvedEmail)
- let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available")
- let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available")
+ let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value))
+ let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
+ let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
return LookupSnapshot(
historyEntryID: nil,
- domain: availability.domain,
+ domain: availability.value.domain,
timestamp: Date(),
- trackedDomainID: nil,
+ trackedDomainID: previousSnapshot?.trackedDomainID,
resolverDisplayName: resolverDisplayName,
resolverURLString: resolverURLString,
totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
dnsSections: dnsSections.value,
dnsError: dnsSections.message,
- availabilityResult: availability,
- suggestions: resolvedSuggestions,
+ availabilityResult: availability.value,
+ suggestions: suggestionsOutcome.value,
sslInfo: sslInfo.value,
sslError: sslInfo.message,
- hstsPreloaded: hsts,
+ hstsPreloaded: hsts.value,
httpHeaders: httpHeadersResult.headers,
httpSecurityGrade: httpHeadersResult.securityGrade,
httpStatusCode: httpHeadersResult.statusCode,
@@ -109,7 +175,9 @@ struct DomainInspectionService {
portScanResults: portScanResults.value,
portScanError: portScanResults.message,
changeSummary: nil,
- isLive: false
+ resultSource: aggregateSource(sectionSources),
+ cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
+ statusMessage: nil
)
}
@@ -122,6 +190,115 @@ struct DomainInspectionService {
.lowercased() ?? domain.lowercased()
}
+ private func track(
+ _ section: LookupSectionKind,
+ source: LookupResultSource,
+ cachedSections: inout Set<LookupSectionKind>,
+ sectionSources: inout [LookupResultSource]
+ ) {
+ sectionSources.append(source)
+ if source != .live {
+ cachedSections.insert(section)
+ }
+ }
+
+ private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource {
+ let normalizedSources = sectionSources.map { source -> LookupResultSource in
+ source == .mixed ? .cached : source
+ }
+
+ let hasLive = normalizedSources.contains(.live)
+ let hasCached = normalizedSources.contains(.cached)
+
+ switch (hasLive, hasCached) {
+ case (true, true):
+ return .mixed
+ case (false, true):
+ return .cached
+ default:
+ return .live
+ }
+ }
+
+ private func canReuseDependentSections(from previousSnapshot: LookupSnapshot?, dnsSections: [DNSSection]) -> Bool {
+ guard let previousSnapshot else { return false }
+ return dnsSignature(for: previousSnapshot.dnsSections) == dnsSignature(for: dnsSections)
+ }
+
+ private func canReuseIPBasedSections(from previousSnapshot: LookupSnapshot?, primaryIP: String?) -> Bool {
+ guard let previousSnapshot else { return false }
+ let previousIP = previousSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+ return primaryIP == previousIP
+ }
+
+ private func dnsSignature(for sections: [DNSSection]) -> String {
+ sections
+ .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
+ .map { section in
+ let records = section.records
+ .sorted { $0.value < $1.value }
+ .map { "\($0.value)|\($0.ttl)" }
+ .joined(separator: ",")
+ let wildcardRecords = section.wildcardRecords
+ .sorted { $0.value < $1.value }
+ .map { "\($0.value)|\($0.ttl)" }
+ .joined(separator: ",")
+ return [
+ section.recordType.rawValue,
+ records,
+ wildcardRecords,
+ section.dnssecSigned.map { $0 ? "signed" : "unsigned" } ?? "unknown",
+ section.error ?? ""
+ ].joined(separator: "#")
+ }
+ .joined(separator: "||")
+ }
+
+ private func normalizeErrors<Value>(in result: ServiceResult<Value>) -> ServiceResult<Value> {
+ switch result {
+ case let .success(value):
+ return .success(value)
+ case let .empty(message):
+ return .empty(message)
+ case let .error(message):
+ return .error(classifiedMessage(from: message))
+ }
+ }
+
+ private func classifiedMessage(from message: String) -> String {
+ let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
+ let lowercasedMessage = normalizedMessage.lowercased()
+
+ if lowercasedMessage.hasPrefix("network error:")
+ || lowercasedMessage.hasPrefix("timeout:")
+ || lowercasedMessage.hasPrefix("rate limit:")
+ || lowercasedMessage.hasPrefix("parsing error:") {
+ return normalizedMessage
+ }
+
+ if lowercasedMessage.contains("timed out") {
+ return "Timeout: Request timed out"
+ }
+ if lowercasedMessage.contains("429")
+ || lowercasedMessage.contains("too many requests")
+ || lowercasedMessage.contains("rate limit") {
+ return "Rate limit: Try again shortly"
+ }
+ if lowercasedMessage.contains("cannot parse")
+ || lowercasedMessage.contains("decoding")
+ || lowercasedMessage.contains("json") {
+ return "Parsing error: Invalid server response"
+ }
+ if lowercasedMessage.contains("offline")
+ || lowercasedMessage.contains("internet connection")
+ || lowercasedMessage.contains("not connected")
+ || lowercasedMessage.contains("network connection") {
+ return "Network error: Offline"
+ }
+
+ return "Network error: \(normalizedMessage)"
+ }
+
private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
switch result {
case let .success(value):
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
index 8cb5c82..7c5a7c0 100644
--- a/DomainReportBuilder.swift
+++ b/DomainReportBuilder.swift
@@ -3,6 +3,7 @@ import Foundation
struct DomainReport: Codable {
let domain: String
let timestamp: Date
+ let resultSource: LookupResultSource
let availability: DomainAvailabilityStatus
let ownership: DomainOwnership?
let dns: DNSResultSummary
@@ -70,6 +71,7 @@ struct DomainReportBuilder {
return DomainReport(
domain: snapshot.domain,
timestamp: snapshot.timestamp,
+ resultSource: snapshot.resultSource,
availability: snapshot.availabilityResult?.status ?? .unknown,
ownership: snapshot.ownership,
dns: DNSResultSummary(
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
index 19271fa..ecfdbd4 100644
--- a/DomainReportExporter.swift
+++ b/DomainReportExporter.swift
@@ -36,6 +36,7 @@ enum DomainReportExporter {
"DomainDig Report",
"Domain: \(report.domain)",
"Timestamp: \(textDateFormatter.string(from: report.timestamp))",
+ "Source: \(report.resultSource.label)",
"Availability: \(availabilityLabel(report.availability))"
]
@@ -212,6 +213,7 @@ enum DomainReportExporter {
let headers = [
"domain",
"timestamp",
+ "result_source",
"availability",
"registrar",
"ownership_expires",
@@ -247,6 +249,7 @@ enum DomainReportExporter {
return [
report.domain,
csvDateFormatter.string(from: report.timestamp),
+ report.resultSource.rawValue,
availabilityLabel(report.availability),
report.ownership?.registrar ?? "",
expirationDate,
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
index 3a35eff..dd1e5f4 100644
--- a/LookupSnapshot.swift
+++ b/LookupSnapshot.swift
@@ -39,7 +39,13 @@ struct LookupSnapshot {
let portScanResults: [PortScanResult]
let portScanError: String?
let changeSummary: DomainChangeSummary?
- let isLive: Bool
+ let resultSource: LookupResultSource
+ let cachedSections: [LookupSectionKind]
+ let statusMessage: String?
+
+ var isLive: Bool {
+ resultSource == .live
+ }
}
extension HistoryEntry {
@@ -83,7 +89,9 @@ extension HistoryEntry {
portScanResults: portScanResults,
portScanError: portScanError,
changeSummary: changeSummary,
- isLive: false
+ resultSource: .snapshot,
+ cachedSections: [],
+ statusMessage: nil
)
}
}