Commit 3846dc2f5d

3846dc2f5dee69fc4ffbc052009a60e17d60e36b

parent: e73d58ee5d

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-22 04:21 UTC

feat(v2.5.0): add caching, request deduplication, and performance improvements

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +39 −39
@@ -12,16 +12,20 @@
1212 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
1313 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
1414 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
15 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
16 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
17 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
18 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
1519/* End PBXBuildFile section */
1620
1721/* Begin PBXFileReference section */
1822 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
19 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
2023 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; };
2124 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
2225 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; };
2326 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
2427 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
28 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
2529/* End PBXFileReference section */
2630
2731/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -33,11 +37,9 @@
3337 );
3438 target = 8B7800682F6090E300933221 /* DomainDig */;
3539 };
36/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
3740 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = {
3841 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
3942 membershipExceptions = (
40 Assets.xcassets,
4143 BatchResultsView.swift,
4244 BatchSweepSummaryView.swift,
4345 ContentView.swift,
@@ -45,13 +47,13 @@
4547 DomainViewModel.swift,
4648 ExportPresenter.swift,
4749 HistoryView.swift,
48 Info.plist,
4950 LocalNotificationService.swift,
5051 SavedDomainsView.swift,
5152 WatchlistView.swift,
5253 );
5354 target = 8BF124FB2F70000100933221 /* DomainDigCLI */;
5455 };
56/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
5557
5658/* Begin PBXFileSystemSynchronizedRootGroup section */
5759 8B78006B2F6090E300933221 /* DomainDig */ = {
@@ -132,7 +134,7 @@
132134 };
133135 8BF124FB2F70000100933221 /* DomainDigCLI */ = {
134136 isa = PBXNativeTarget;
135 buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */;
137 buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */;
136138 buildPhases = (
137139 8BF124FC2F70000100933221 /* Sources */,
138140 8BF124FD2F70000100933221 /* Frameworks */,
@@ -201,7 +203,6 @@
201203 isa = PBXSourcesBuildPhase;
202204 buildActionMask = 2147483647;
203205 files = (
204 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
205206 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
206207 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
207208 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
@@ -209,6 +210,18 @@
209210 );
210211 runOnlyForDeploymentPostprocessing = 0;
211212 };
213 8BF124FC2F70000100933221 /* Sources */ = {
214 isa = PBXSourcesBuildPhase;
215 buildActionMask = 2147483647;
216 files = (
217 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
218 8BBFEFCA2F987E8700E8E144 /* DomainInspectionService.swift in Sources */,
219 8BBFEFCB2F987E8700E8E144 /* DomainReportBuilder.swift in Sources */,
220 8BBFEFCC2F987E8700E8E144 /* DomainReportExporter.swift in Sources */,
221 8BBFEFCD2F987E8700E8E144 /* LookupSnapshot.swift in Sources */,
222 );
223 runOnlyForDeploymentPostprocessing = 0;
224 };
212225/* End PBXSourcesBuildPhase section */
213226
214227/* Begin XCBuildConfiguration section */
@@ -341,7 +354,7 @@
341354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
342355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
343356 CODE_SIGN_STYLE = Automatic;
344 CURRENT_PROJECT_VERSION = 17;
357 CURRENT_PROJECT_VERSION = 18;
345358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
346359 ENABLE_PREVIEWS = YES;
347360 GENERATE_INFOPLIST_FILE = YES;
@@ -358,7 +371,7 @@
358371 "$(inherited)",
359372 "@executable_path/Frameworks",
360373 );
361 MARKETING_VERSION = 2.4.0;
374 MARKETING_VERSION = 2.5.0;
362375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
363376 PRODUCT_NAME = "$(TARGET_NAME)";
364377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -377,7 +390,7 @@
377390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
378391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
379392 CODE_SIGN_STYLE = Automatic;
380 CURRENT_PROJECT_VERSION = 17;
393 CURRENT_PROJECT_VERSION = 18;
381394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
382395 ENABLE_PREVIEWS = YES;
383396 GENERATE_INFOPLIST_FILE = YES;
@@ -394,7 +407,7 @@
394407 "$(inherited)",
395408 "@executable_path/Frameworks",
396409 );
397 MARKETING_VERSION = 2.4.0;
410 MARKETING_VERSION = 2.5.0;
398411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
399412 PRODUCT_NAME = "$(TARGET_NAME)";
400413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -407,44 +420,22 @@
407420 };
408421 name = Release;
409422 };
410 8BF125002F70000100933221 /* Debug */ = {
423 8BBFF0272F987E8700E8E144 /* Debug */ = {
411424 isa = XCBuildConfiguration;
412425 buildSettings = {
413 CODE_SIGNING_ALLOWED = NO;
414 CODE_SIGN_STYLE = Automatic;
415 CURRENT_PROJECT_VERSION = 17;
416 DEVELOPMENT_TEAM = ZCNAX3VL9D;
417 ENABLE_TESTABILITY = YES;
418 GENERATE_INFOPLIST_FILE = YES;
419 MACOSX_DEPLOYMENT_TARGET = 14.0;
420 MARKETING_VERSION = 2.4.0;
421 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
426 COPY_PHASE_STRIP = NO;
427 GCC_DYNAMIC_NO_PIC = NO;
428 GCC_OPTIMIZATION_LEVEL = 0;
422429 PRODUCT_NAME = domaindig;
423 SDKROOT = macosx;
424 SWIFT_APPROACHABLE_CONCURRENCY = YES;
425 SWIFT_EMIT_LOC_STRINGS = NO;
426 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
427 SWIFT_VERSION = 5.0;
428430 };
429431 name = Debug;
430432 };
431 8BF125012F70000100933221 /* Release */ = {
433 8BBFF0282F987E8700E8E144 /* Release */ = {
432434 isa = XCBuildConfiguration;
433435 buildSettings = {
434 CODE_SIGNING_ALLOWED = NO;
435 CODE_SIGN_STYLE = Automatic;
436 CURRENT_PROJECT_VERSION = 17;
437 DEVELOPMENT_TEAM = ZCNAX3VL9D;
438 GENERATE_INFOPLIST_FILE = YES;
439 MACOSX_DEPLOYMENT_TARGET = 14.0;
440 MARKETING_VERSION = 2.4.0;
441 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
436 COPY_PHASE_STRIP = YES;
437 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
442438 PRODUCT_NAME = domaindig;
443 SDKROOT = macosx;
444 SWIFT_APPROACHABLE_CONCURRENCY = YES;
445 SWIFT_EMIT_LOC_STRINGS = NO;
446 SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
447 SWIFT_VERSION = 5.0;
448439 };
449440 name = Release;
450441 };
@@ -469,6 +460,15 @@
469460 defaultConfigurationIsVisible = 0;
470461 defaultConfigurationName = Release;
471462 };
463 8BBFF0292F987EF700E8E144 /* Build configuration list */ = {
464 isa = XCConfigurationList;
465 buildConfigurations = (
466 8BBFF0272F987E8700E8E144 /* Debug */,
467 8BBFF0282F987E8700E8E144 /* Release */,
468 );
469 defaultConfigurationIsVisible = 0;
470 defaultConfigurationName = Release;
471 };
472472/* End XCConfigurationList section */
473473 };
474474 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig/BatchResultsView.swift +4 −1
@@ -58,6 +58,9 @@ struct BatchResultRowView: View {
5858 .foregroundStyle(.primary)
5959 .lineLimit(1)
6060 Spacer(minLength: 8)
61 Text(result.resultSource.label.lowercased())
62 .font(.system(.caption2, design: .monospaced))
63 .foregroundStyle(.secondary)
6164 Text(result.quickStatus)
6265 .font(.system(.caption2, design: .monospaced))
6366 .foregroundStyle(quickStatusColor)
@@ -84,7 +87,7 @@ struct BatchResultRowView: View {
8487 if let errorMessage = result.errorMessage {
8588 Text(errorMessage)
8689 .font(.system(.caption2, design: .monospaced))
87 .foregroundStyle(.red)
90 .foregroundStyle(result.status == .failed ? .red : .secondary)
8891 }
8992 }
9093 .frame(maxWidth: .infinity, alignment: .leading)
DomainDig/ContentView.swift +50
@@ -30,6 +30,10 @@ struct ContentView: View {
3030 }
3131 if viewModel.hasRun {
3232 actionButtons
33 if let statusMessage = viewModel.currentStatusMessage ?? (viewModel.currentResultSource != .live ? viewModel.currentResultSource.label : nil) {
34 LookupStatusBannerView(message: statusMessage, resultSource: viewModel.currentResultSource)
35 .padding(.top, 8)
36 }
3337 SummaryView(fields: viewModel.summaryFields)
3438 .padding(.top, 8)
3539 if let changeSummary = viewModel.currentChangeSummary {
@@ -494,6 +498,52 @@ struct SummaryView: View {
494498 }
495499}
496500
501struct LookupStatusBannerView: View {
502 let message: String
503 let resultSource: LookupResultSource
504
505 var body: some View {
506 HStack(spacing: 8) {
507 Image(systemName: iconName)
508 .font(.caption)
509 Text(message)
510 .font(.system(.caption, design: .monospaced))
511 Spacer()
512 }
513 .foregroundStyle(color)
514 .padding(8)
515 .frame(maxWidth: .infinity, alignment: .leading)
516 .background(color.opacity(0.12))
517 .clipShape(RoundedRectangle(cornerRadius: 8))
518 }
519
520 private var color: Color {
521 switch resultSource {
522 case .live:
523 return .green
524 case .cached:
525 return .secondary
526 case .mixed:
527 return .yellow
528 case .snapshot:
529 return .orange
530 }
531 }
532
533 private var iconName: String {
534 switch resultSource {
535 case .live:
536 return "bolt.horizontal"
537 case .cached:
538 return "clock.arrow.trianglehead.counterclockwise.rotate.90"
539 case .mixed:
540 return "arrow.triangle.branch"
541 case .snapshot:
542 return "archivebox"
543 }
544 }
545}
546
497547struct DomainChangeSummaryView: View {
498548 let summary: DomainChangeSummary
499549
DomainDig/DomainViewModel.swift +217 −50
@@ -184,6 +184,12 @@ final class DomainViewModel {
184184 private var activeBatchDomains: [String] = []
185185 private var lastBatchStartedAt: Date?
186186 private let reportBuilder = DomainReportBuilder()
187 private let inspectionService = DomainInspectionService()
188 private(set) var currentResultSource: LookupResultSource = .live
189 private(set) var currentCachedSections: [LookupSectionKind] = []
190 private(set) var currentStatusMessage: String?
191 private(set) var currentSnapshotTimestamp = Date()
192 private(set) var currentHistoryEntryID: UUID?
187193
188194 private static let recentSearchesKey = "recentSearches"
189195 private static let maxRecent = 20
@@ -355,9 +361,9 @@ final class DomainViewModel {
355361
356362 var currentSnapshot: LookupSnapshot {
357363 LookupSnapshot(
358 historyEntryID: nil,
364 historyEntryID: currentHistoryEntryID,
359365 domain: searchedDomain,
360 timestamp: Date(),
366 timestamp: currentSnapshotTimestamp,
361367 trackedDomainID: currentTrackedDomain?.id,
362368 resolverDisplayName: resolverDisplayName,
363369 resolverURLString: resolverURLString,
@@ -393,7 +399,9 @@ final class DomainViewModel {
393399 portScanResults: allPortScanResults,
394400 portScanError: combinedPortScanError,
395401 changeSummary: currentChangeSummary,
396 isLive: true
402 resultSource: currentResultSource,
403 cachedSections: currentCachedSections,
404 statusMessage: currentStatusMessage
397405 )
398406 }
399407
@@ -746,51 +754,163 @@ final class DomainViewModel {
746754 }
747755
748756 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
749 await withTaskGroup(of: Void.self) { group in
750 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) }
751 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) }
752 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) }
753 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) }
754 group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) }
755 group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) }
756 }
757 let previous = previousSnapshot(
758 for: domain,
759 trackedDomainID: currentTrackedDomain?.id,
760 replacingLatest: false
761 )
762 let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
763 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
757764
758 await withTaskGroup(of: Void.self) { group in
759 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) }
760 group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) }
761 group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) }
762 group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) }
765 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
766 applySnapshot(snapshot)
767 lastLookupDurationMs = snapshot.totalLookupDurationMs
768 refreshingTrackedDomainID = nil
769
770 guard snapshot.statusMessage == nil else {
771 return history.first(where: { $0.id == snapshot.historyEntryID })
763772 }
764773
765 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
774 return saveHistoryEntry(replaceLatest: false)
775 }
766776
767 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
768 let primaryIP = primaryIPAddress(from: dnsSections)
777 private func applySnapshot(_ snapshot: LookupSnapshot) {
778 currentHistoryEntryID = snapshot.historyEntryID
779 currentSnapshotTimestamp = snapshot.timestamp
780 currentResultSource = snapshot.resultSource
781 currentCachedSections = snapshot.cachedSections
782 currentStatusMessage = snapshot.statusMessage
783 currentChangeSummary = snapshot.changeSummary
784 currentDiffSections = []
785 ownershipDiff = []
769786
770 await withTaskGroup(of: Void.self) { group in
771 group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) }
772 if let primaryIP {
773 group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) }
774 group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) }
775 } else {
776 group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) }
777 }
778 }
787 dnsSections = snapshot.dnsSections
788 dnsError = snapshot.dnsError
789 availabilityResult = snapshot.availabilityResult
790 suggestions = snapshot.suggestions
791 sslInfo = snapshot.sslInfo
792 sslError = snapshot.sslError
793 hstsPreloaded = snapshot.hstsPreloaded
794 httpHeaders = snapshot.httpHeaders
795 httpSecurityGrade = snapshot.httpSecurityGrade
796 httpStatusCode = snapshot.httpStatusCode
797 httpResponseTimeMs = snapshot.httpResponseTimeMs
798 httpProtocol = snapshot.httpProtocol
799 http3Advertised = snapshot.http3Advertised
800 httpHeadersError = snapshot.httpHeadersError
801 reachabilityResults = snapshot.reachabilityResults
802 reachabilityError = snapshot.reachabilityError
803 ipGeolocation = snapshot.ipGeolocation
804 ipGeolocationError = snapshot.ipGeolocationError
805 emailSecurity = snapshot.emailSecurity
806 emailSecurityError = snapshot.emailSecurityError
807 ownershipResult = snapshot.ownership
808 ownershipError = snapshot.ownershipError
809 ptrRecord = snapshot.ptrRecord
810 ptrError = snapshot.ptrError
811 redirectChain = snapshot.redirectChain
812 redirectChainError = snapshot.redirectChainError
813 subdomains = snapshot.subdomains
814 subdomainsError = snapshot.subdomainsError
815 portScanResults = snapshot.portScanResults.filter { $0.kind == .standard }
816 customPortResults = snapshot.portScanResults.filter { $0.kind == .custom }
817 portScanError = snapshot.portScanError
818 customPortScanError = nil
779819
780 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
820 dnsLoading = false
821 availabilityLoading = false
822 suggestionsLoading = false
823 sslLoading = false
824 hstsLoading = false
825 httpHeadersLoading = false
826 reachabilityLoading = false
827 ipGeolocationLoading = false
828 emailSecurityLoading = false
829 ownershipLoading = false
830 ptrLoading = false
831 redirectChainLoading = false
832 subdomainsLoading = false
833 portScanLoading = false
834 customPortScanLoading = false
835 }
781836
782 if availabilityResult?.status == .registered {
783 await runSuggestions(domain: domain, lookupID: lookupID)
784 } else {
785 suggestions = []
786 suggestionsLoading = false
837 private static func resolvedSnapshotAfterFallback(
838 _ snapshot: LookupSnapshot,
839 previousSnapshot: LookupSnapshot?
840 ) -> LookupSnapshot {
841 guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
842 return snapshot
843 }
844
845 return LookupSnapshot(
846 historyEntryID: previousSnapshot.historyEntryID,
847 domain: previousSnapshot.domain,
848 timestamp: previousSnapshot.timestamp,
849 trackedDomainID: previousSnapshot.trackedDomainID,
850 resolverDisplayName: previousSnapshot.resolverDisplayName,
851 resolverURLString: previousSnapshot.resolverURLString,
852 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
853 dnsSections: previousSnapshot.dnsSections,
854 dnsError: previousSnapshot.dnsError,
855 availabilityResult: previousSnapshot.availabilityResult,
856 suggestions: previousSnapshot.suggestions,
857 sslInfo: previousSnapshot.sslInfo,
858 sslError: previousSnapshot.sslError,
859 hstsPreloaded: previousSnapshot.hstsPreloaded,
860 httpHeaders: previousSnapshot.httpHeaders,
861 httpSecurityGrade: previousSnapshot.httpSecurityGrade,
862 httpStatusCode: previousSnapshot.httpStatusCode,
863 httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
864 httpProtocol: previousSnapshot.httpProtocol,
865 http3Advertised: previousSnapshot.http3Advertised,
866 httpHeadersError: previousSnapshot.httpHeadersError,
867 reachabilityResults: previousSnapshot.reachabilityResults,
868 reachabilityError: previousSnapshot.reachabilityError,
869 ipGeolocation: previousSnapshot.ipGeolocation,
870 ipGeolocationError: previousSnapshot.ipGeolocationError,
871 emailSecurity: previousSnapshot.emailSecurity,
872 emailSecurityError: previousSnapshot.emailSecurityError,
873 ownership: previousSnapshot.ownership,
874 ownershipError: previousSnapshot.ownershipError,
875 ptrRecord: previousSnapshot.ptrRecord,
876 ptrError: previousSnapshot.ptrError,
877 redirectChain: previousSnapshot.redirectChain,
878 redirectChainError: previousSnapshot.redirectChainError,
879 subdomains: previousSnapshot.subdomains,
880 subdomainsError: previousSnapshot.subdomainsError,
881 portScanResults: previousSnapshot.portScanResults,
882 portScanError: previousSnapshot.portScanError,
883 changeSummary: previousSnapshot.changeSummary,
884 resultSource: .snapshot,
885 cachedSections: [],
886 statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
887 )
888 }
889
890 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
891 let candidateMessages = [
892 snapshot.dnsError,
893 snapshot.httpHeadersError,
894 snapshot.sslError,
895 snapshot.ownershipError,
896 snapshot.subdomainsError,
897 snapshot.redirectChainError,
898 snapshot.ipGeolocationError
899 ]
900 .compactMap { $0?.lowercased() }
901
902 guard !candidateMessages.isEmpty else { return false }
903 let failedDueToConnectivity = candidateMessages.allSatisfy { message in
904 message.hasPrefix("network error:") || message.hasPrefix("timeout:") || message.hasPrefix("rate limit:")
787905 }
788906
789 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
790 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) }
791 let entry = saveHistoryEntry(replaceLatest: false)
792 refreshingTrackedDomainID = nil
793 return entry
907 let hasMaterialData = !snapshot.dnsSections.isEmpty
908 || !snapshot.httpHeaders.isEmpty
909 || snapshot.sslInfo != nil
910 || snapshot.ownership != nil
911 || !snapshot.subdomains.isEmpty
912
913 return failedDueToConnectivity && !hasMaterialData
794914 }
795915
796916 private func runDNS(domain: String, lookupID: UUID) async {
@@ -1044,11 +1164,12 @@ final class DomainViewModel {
10441164 customPortScanLoading = false
10451165 }
10461166
1047 private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
1167 private static func performBatchLookup(domain: String, previousSnapshot: LookupSnapshot?) async -> BatchLookupPayload? {
10481168 guard !Task.isCancelled else { return nil }
1049 let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain)
1169 let inspectionService = DomainInspectionService()
1170 let snapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
10501171 guard !Task.isCancelled else { return nil }
1051 return BatchLookupPayload(snapshot: snapshot)
1172 return BatchLookupPayload(snapshot: resolvedSnapshotAfterFallback(snapshot, previousSnapshot: previousSnapshot))
10521173 }
10531174
10541175 private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
@@ -1141,6 +1262,10 @@ final class DomainViewModel {
11411262 portScanError: snapshot.portScanError
11421263 )
11431264
1265 if updateCurrentState {
1266 currentHistoryEntryID = entry.id
1267 }
1268
11441269 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
11451270 history[0] = entry
11461271 } else {
@@ -1318,6 +1443,11 @@ final class DomainViewModel {
13181443 addRecentSearch(target)
13191444 searchedDomain = target
13201445 hasRun = true
1446 currentHistoryEntryID = nil
1447 currentSnapshotTimestamp = Date()
1448 currentResultSource = .live
1449 currentCachedSections = []
1450 currentStatusMessage = nil
13211451 currentDiffSections = []
13221452 currentChangeSummary = nil
13231453 ownershipDiff = []
@@ -1424,9 +1554,10 @@ final class DomainViewModel {
14241554 refreshingTrackedDomainID = trackedDomain(for: domain)?.id
14251555 }
14261556 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
1557 let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false)
14271558
1428 group.addTask { [domain] in
1429 let payload = await Self.performBatchLookup(domain: domain)
1559 group.addTask { [domain, previousSnapshot] in
1560 let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot)
14301561 return (domain, payload)
14311562 }
14321563 }
@@ -1441,13 +1572,21 @@ final class DomainViewModel {
14411572 status: .failed,
14421573 quickStatus: "Failed",
14431574 entry: nil,
1575 resultSource: .live,
14441576 errorMessage: "Lookup cancelled"
14451577 )
14461578 batchCompletedCount += 1
14471579 return
14481580 }
14491581
1450 let entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
1582 let entry: HistoryEntry?
1583 if payload.snapshot.statusMessage == nil {
1584 entry = saveHistoryEntry(from: payload.snapshot, replaceLatest: false, updateCurrentState: false)
1585 } else {
1586 entry = payload.snapshot.historyEntryID.flatMap { id in
1587 history.first(where: { $0.id == id })
1588 }
1589 }
14511590 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
14521591 let quickStatus: String
14531592 if entry?.changeSummary?.hasChanges == true {
@@ -1463,7 +1602,8 @@ final class DomainViewModel {
14631602 status: .completed,
14641603 quickStatus: quickStatus,
14651604 entry: entry,
1466 errorMessage: nil
1605 resultSource: payload.snapshot.resultSource,
1606 errorMessage: payload.snapshot.statusMessage
14671607 )
14681608 batchCompletedCount += 1
14691609 }
@@ -1503,6 +1643,7 @@ final class DomainViewModel {
15031643 status: BatchLookupStatus,
15041644 quickStatus: String,
15051645 entry: HistoryEntry?,
1646 resultSource: LookupResultSource = .live,
15061647 errorMessage: String?
15071648 ) {
15081649 guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
@@ -1513,6 +1654,7 @@ final class DomainViewModel {
15131654 id: batchResults[index].id,
15141655 domain: domain,
15151656 historyEntryID: entry?.id,
1657 resultSource: resultSource,
15161658 availability: entry?.availabilityResult?.status,
15171659 primaryIP: entry?.primaryIP,
15181660 quickStatus: quickStatus,
@@ -1573,6 +1715,11 @@ final class DomainViewModel {
15731715 customPortResults = []
15741716 customPortScanError = nil
15751717 customPortScanLoading = false
1718 currentHistoryEntryID = nil
1719 currentSnapshotTimestamp = Date()
1720 currentResultSource = .live
1721 currentCachedSections = []
1722 currentStatusMessage = nil
15761723 }
15771724
15781725 private func setAllLoadingStates(_ loading: Bool) {
@@ -1715,7 +1862,9 @@ final class DomainViewModel {
17151862 portScanResults: [],
17161863 portScanError: nil,
17171864 changeSummary: trackedDomain.lastChangeSummary,
1718 isLive: false
1865 resultSource: .snapshot,
1866 cachedSections: [],
1867 statusMessage: nil
17191868 )
17201869 }
17211870
@@ -1801,7 +1950,8 @@ final class DomainViewModel {
18011950 SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
18021951 SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
18031952 SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)),
1804 SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary)
1953 SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary),
1954 SummaryFieldViewData(label: "Source", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot))
18051955 ]
18061956 }
18071957
@@ -1809,7 +1959,7 @@ final class DomainViewModel {
18091959 var rows = [
18101960 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
18111961 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary),
1812 InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning),
1962 InfoRowViewData(label: snapshot.statusMessage == nil ? "Result" : "Snapshot", value: snapshot.statusMessage ?? snapshot.resultSource.label, tone: sourceTone(for: snapshot)),
18131963 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
18141964 ]
18151965 rows.insert(
@@ -2083,7 +2233,7 @@ final class DomainViewModel {
20832233 "DomainDig Export",
20842234 "Domain: \(snapshot.domain)",
20852235 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
2086 "Mode: \(snapshot.isLive ? "Live" : "Snapshot")",
2236 "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)",
20872237 "Resolver: \(snapshot.resolverDisplayName)",
20882238 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
20892239 "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
@@ -2417,6 +2567,23 @@ final class DomainViewModel {
24172567 }
24182568 }
24192569
2570 private static func sourceTone(for snapshot: LookupSnapshot) -> ResultTone {
2571 if snapshot.statusMessage != nil {
2572 return .warning
2573 }
2574
2575 switch snapshot.resultSource {
2576 case .live:
2577 return .success
2578 case .cached:
2579 return .secondary
2580 case .mixed:
2581 return .warning
2582 case .snapshot:
2583 return .warning
2584 }
2585 }
2586
24202587 private static func securityGradeTone(_ grade: String) -> ResultTone {
24212588 switch grade {
24222589 case "A", "B":
DomainDig/LookupRuntime.swift added +289
@@ -0,0 +1,289 @@
1import Foundation
2
3struct CachedLookupResult<Value> {
4 let value: Value
5 let source: LookupResultSource
6}
7
8actor LookupRuntime {
9 static let shared = LookupRuntime()
10
11 private let ttl: TimeInterval = 300
12
13 private enum RequestKey: Hashable {
14 case domain(String, LookupSectionKind)
15 case subject(String, LookupSectionKind)
16 }
17
18 private enum RateLimitBucket: Hashable {
19 case crtsh
20 case rdap
21 case ipGeolocation
22
23 var minimumSpacing: TimeInterval {
24 switch self {
25 case .crtsh:
26 return 1.0
27 case .rdap:
28 return 0.75
29 case .ipGeolocation:
30 return 0.75
31 }
32 }
33 }
34
35 private enum CachedPayload {
36 case dns(ServiceResult<[DNSSection]>)
37 case availability(DomainAvailabilityResult)
38 case ssl(ServiceResult<SSLCertificateInfo>)
39 case hsts(Bool?)
40 case http(ServiceResult<HTTPHeadersResult>)
41 case reachability(ServiceResult<[PortReachability]>)
42 case ownership(ServiceResult<DomainOwnership>)
43 case redirect(ServiceResult<[RedirectHop]>)
44 case subdomains(ServiceResult<[DiscoveredSubdomain]>)
45 case portScan(ServiceResult<[PortScanResult]>)
46 case email(ServiceResult<EmailSecurityResult>)
47 case ptr(ServiceResult<String>)
48 case ipGeolocation(ServiceResult<IPGeolocation>)
49 case suggestions([DomainSuggestionResult])
50 }
51
52 private struct CacheEntry {
53 let payload: CachedPayload
54 let expiresAt: Date
55 }
56
57 private var cache: [RequestKey: CacheEntry] = [:]
58 private var inFlight: [RequestKey: Task<CachedPayload, Never>] = [:]
59 private var nextAllowedAt: [RateLimitBucket: Date] = [:]
60
61 func dns(domain: String) async -> CachedLookupResult<ServiceResult<[DNSSection]>> {
62 await execute(
63 key: .domain(domain, .dns),
64 extract: { payload in
65 guard case let .dns(result) = payload else { return nil }
66 return result
67 },
68 operation: {
69 .dns(await DNSLookupService.lookupAll(domain: domain))
70 }
71 )
72 }
73
74 func availability(domain: String) async -> CachedLookupResult<DomainAvailabilityResult> {
75 await execute(
76 key: .domain(domain, .availability),
77 extract: { payload in
78 guard case let .availability(result) = payload else { return nil }
79 return result
80 },
81 operation: {
82 .availability(await DomainAvailabilityService.check(domain: domain))
83 }
84 )
85 }
86
87 func ssl(domain: String) async -> CachedLookupResult<ServiceResult<SSLCertificateInfo>> {
88 await execute(
89 key: .domain(domain, .ssl),
90 extract: { payload in
91 guard case let .ssl(result) = payload else { return nil }
92 return result
93 },
94 operation: {
95 .ssl(await SSLCheckService.check(domain: domain))
96 }
97 )
98 }
99
100 func hsts(domain: String) async -> CachedLookupResult<Bool?> {
101 await execute(
102 key: .domain(domain, .hsts),
103 extract: { payload in
104 guard case let .hsts(result) = payload else { return nil }
105 return result
106 },
107 operation: {
108 .hsts(await SSLCheckService.checkHSTSPreload(domain: domain))
109 }
110 )
111 }
112
113 func http(domain: String) async -> CachedLookupResult<ServiceResult<HTTPHeadersResult>> {
114 await execute(
115 key: .domain(domain, .httpHeaders),
116 extract: { payload in
117 guard case let .http(result) = payload else { return nil }
118 return result
119 },
120 operation: {
121 .http(await HTTPHeadersService.fetch(domain: domain))
122 }
123 )
124 }
125
126 func reachability(domain: String) async -> CachedLookupResult<ServiceResult<[PortReachability]>> {
127 await execute(
128 key: .domain(domain, .reachability),
129 extract: { payload in
130 guard case let .reachability(result) = payload else { return nil }
131 return result
132 },
133 operation: {
134 .reachability(await ReachabilityService.checkAll(domain: domain))
135 }
136 )
137 }
138
139 func ownership(domain: String) async -> CachedLookupResult<ServiceResult<DomainOwnership>> {
140 await execute(
141 key: .domain(domain, .ownership),
142 rateLimitBucket: .rdap,
143 extract: { payload in
144 guard case let .ownership(result) = payload else { return nil }
145 return result
146 },
147 operation: {
148 .ownership(await DomainOwnershipService.lookup(domain: domain))
149 }
150 )
151 }
152
153 func redirectChain(domain: String) async -> CachedLookupResult<ServiceResult<[RedirectHop]>> {
154 await execute(
155 key: .domain(domain, .redirectChain),
156 extract: { payload in
157 guard case let .redirect(result) = payload else { return nil }
158 return result
159 },
160 operation: {
161 .redirect(await RedirectChainService.trace(domain: domain))
162 }
163 )
164 }
165
166 func subdomains(domain: String) async -> CachedLookupResult<ServiceResult<[DiscoveredSubdomain]>> {
167 await execute(
168 key: .domain(domain, .subdomains),
169 rateLimitBucket: .crtsh,
170 extract: { payload in
171 guard case let .subdomains(result) = payload else { return nil }
172 return result
173 },
174 operation: {
175 .subdomains(await SubdomainDiscoveryService.discover(for: domain))
176 }
177 )
178 }
179
180 func portScan(domain: String) async -> CachedLookupResult<ServiceResult<[PortScanResult]>> {
181 await execute(
182 key: .domain(domain, .portScan),
183 extract: { payload in
184 guard case let .portScan(result) = payload else { return nil }
185 return result
186 },
187 operation: {
188 .portScan(await PortScanService.scanAll(domain: domain))
189 }
190 )
191 }
192
193 func email(domain: String, txtRecords: [DNSRecord]) async -> CachedLookupResult<ServiceResult<EmailSecurityResult>> {
194 await execute(
195 key: .domain(domain, .emailSecurity),
196 extract: { payload in
197 guard case let .email(result) = payload else { return nil }
198 return result
199 },
200 operation: {
201 .email(await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords))
202 }
203 )
204 }
205
206 func ptr(ip: String, resolverURLString: String) async -> CachedLookupResult<ServiceResult<String>> {
207 await execute(
208 key: .subject("\(resolverURLString)|\(ip)", .ptr),
209 extract: { payload in
210 guard case let .ptr(result) = payload else { return nil }
211 return result
212 },
213 operation: {
214 .ptr(await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString))
215 }
216 )
217 }
218
219 func ipGeolocation(ip: String) async -> CachedLookupResult<ServiceResult<IPGeolocation>> {
220 await execute(
221 key: .subject(ip, .ipGeolocation),
222 rateLimitBucket: .ipGeolocation,
223 extract: { payload in
224 guard case let .ipGeolocation(result) = payload else { return nil }
225 return result
226 },
227 operation: {
228 .ipGeolocation(await IPGeolocationService.lookup(ip: ip))
229 }
230 )
231 }
232
233 func suggestions(domain: String) async -> CachedLookupResult<[DomainSuggestionResult]> {
234 await execute(
235 key: .domain(domain, .suggestions),
236 extract: { payload in
237 guard case let .suggestions(result) = payload else { return nil }
238 return result
239 },
240 operation: {
241 .suggestions(await DomainAvailabilityService.suggestions(for: domain))
242 }
243 )
244 }
245
246 private func execute<T>(
247 key: RequestKey,
248 rateLimitBucket: RateLimitBucket? = nil,
249 extract: @escaping (CachedPayload) -> T?,
250 operation: @escaping @Sendable () async -> CachedPayload
251 ) async -> CachedLookupResult<T> {
252 if let cachedEntry = cache[key], cachedEntry.expiresAt > Date(), let value = extract(cachedEntry.payload) {
253 return CachedLookupResult(value: value, source: .cached)
254 }
255
256 if let task = inFlight[key], let value = extract(await task.value) {
257 return CachedLookupResult(value: value, source: .mixed)
258 }
259
260 let task = Task<CachedPayload, Never> {
261 if let rateLimitBucket {
262 await self.enforceRateLimit(for: rateLimitBucket)
263 }
264 return await operation()
265 }
266 inFlight[key] = task
267
268 let payload = await task.value
269 cache[key] = CacheEntry(payload: payload, expiresAt: Date().addingTimeInterval(ttl))
270 inFlight[key] = nil
271
272 guard let value = extract(payload) else {
273 fatalError("LookupRuntime payload extraction mismatch")
274 }
275
276 return CachedLookupResult(value: value, source: .live)
277 }
278
279 private func enforceRateLimit(for bucket: RateLimitBucket) async {
280 let now = Date()
281 if let nextAllowed = nextAllowedAt[bucket], nextAllowed > now {
282 let delay = nextAllowed.timeIntervalSince(now)
283 if delay > 0 {
284 try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
285 }
286 }
287 nextAllowedAt[bucket] = Date().addingTimeInterval(bucket.minimumSpacing)
288 }
289}
DomainDig/Models.swift +40
@@ -6,6 +6,43 @@ enum ServiceResult<Value> {
66 case error(String)
77}
88
9enum LookupResultSource: String, Codable {
10 case live
11 case cached
12 case mixed
13 case snapshot
14
15 var label: String {
16 switch self {
17 case .live:
18 return "Live"
19 case .cached:
20 return "Cached"
21 case .mixed:
22 return "Mixed"
23 case .snapshot:
24 return "Snapshot"
25 }
26 }
27}
28
29enum LookupSectionKind: String, Codable, CaseIterable {
30 case dns
31 case availability
32 case ssl
33 case hsts
34 case httpHeaders
35 case reachability
36 case ipGeolocation
37 case emailSecurity
38 case ownership
39 case ptr
40 case redirectChain
41 case subdomains
42 case portScan
43 case suggestions
44}
45
946enum DomainAvailabilityStatus: String, Codable {
1047 case available
1148 case registered
@@ -134,6 +171,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
134171 let id: UUID
135172 let domain: String
136173 let historyEntryID: UUID?
174 let resultSource: LookupResultSource
137175 let availability: DomainAvailabilityStatus?
138176 let primaryIP: String?
139177 let quickStatus: String
@@ -148,6 +186,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
148186 id: UUID = UUID(),
149187 domain: String,
150188 historyEntryID: UUID?,
189 resultSource: LookupResultSource = .live,
151190 availability: DomainAvailabilityStatus?,
152191 primaryIP: String?,
153192 quickStatus: String,
@@ -161,6 +200,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
161200 self.id = id
162201 self.domain = domain
163202 self.historyEntryID = historyEntryID
203 self.resultSource = resultSource
164204 self.availability = availability
165205 self.primaryIP = primaryIP
166206 self.quickStatus = quickStatus
DomainDig/RDAPService.swift +2 −29
@@ -5,7 +5,7 @@ enum RDAPService {
55 let normalizedDomain = normalize(domain)
66 guard !normalizedDomain.isEmpty else { return nil }
77
8 switch await cache.response(for: normalizedDomain) {
8 switch await fetchRDAPResponse(for: normalizedDomain) {
99 case let .success(response):
1010 return response.isDomainRecord ? .registered : nil
1111 case .empty:
@@ -21,7 +21,7 @@ enum RDAPService {
2121 return .empty("Unavailable")
2222 }
2323
24 switch await cache.response(for: normalizedDomain) {
24 switch await fetchRDAPResponse(for: normalizedDomain) {
2525 case let .success(response):
2626 let ownership = DomainOwnership(
2727 registrar: response.registrarName,
@@ -39,8 +39,6 @@ enum RDAPService {
3939 }
4040 }
4141
42 private static let cache = RDAPCache()
43
4442 private static func normalize(_ domain: String) -> String {
4543 domain
4644 .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -48,31 +46,6 @@ enum RDAPService {
4846 }
4947}
5048
51private actor RDAPCache {
52 private var cachedResponses: [String: ServiceResult<RDAPDomainResponse>] = [:]
53 private var inFlightTasks: [String: Task<ServiceResult<RDAPDomainResponse>, Never>] = [:]
54
55 func response(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
56 if let cachedResponse = cachedResponses[domain] {
57 return cachedResponse
58 }
59
60 if let inFlightTask = inFlightTasks[domain] {
61 return await inFlightTask.value
62 }
63
64 let task = Task<ServiceResult<RDAPDomainResponse>, Never> {
65 await fetchRDAPResponse(for: domain)
66 }
67 inFlightTasks[domain] = task
68
69 let result = await task.value
70 cachedResponses[domain] = result
71 inFlightTasks[domain] = nil
72 return result
73 }
74}
75
7649private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
7750 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
7851 return .error("Unavailable")
DomainDig/SubdomainDiscoveryService.swift +3 −42
@@ -7,55 +7,16 @@ enum SubdomainDiscoveryService {
77 return .empty("No passive subdomains found")
88 }
99
10 return await cache.subdomains(for: normalizedDomain, limit: limit)
10 return await fetchSubdomains(for: normalizedDomain, limit: limit)
1111 }
1212
13 private static let cache = SubdomainDiscoveryCache()
14
1513 private static func normalize(_ domain: String) -> String {
1614 domain
1715 .trimmingCharacters(in: .whitespacesAndNewlines)
1816 .lowercased()
1917 }
20}
21
22private actor SubdomainDiscoveryCache {
23 private var cachedResults: [String: ServiceResult<[DiscoveredSubdomain]>] = [:]
24 private var inFlightTasks: [String: Task<ServiceResult<[DiscoveredSubdomain]>, Never>] = [:]
25 private var lastRequestAt: Date?
26
27 func subdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
28 if let cachedResult = cachedResults[domain] {
29 return cachedResult
30 }
31
32 if let inFlightTask = inFlightTasks[domain] {
33 return await inFlightTask.value
34 }
35
36 let task = Task<ServiceResult<[DiscoveredSubdomain]>, Never> {
37 await enforceRateLimit()
38 return await fetchSubdomains(for: domain, limit: limit)
39 }
40 inFlightTasks[domain] = task
41
42 let result = await task.value
43 cachedResults[domain] = result
44 inFlightTasks[domain] = nil
45 return result
46 }
47
48 private func enforceRateLimit() async {
49 if let lastRequestAt {
50 let delay = max(0, 0.75 - Date().timeIntervalSince(lastRequestAt))
51 if delay > 0 {
52 try? await Task.sleep(for: .seconds(delay))
53 }
54 }
55 lastRequestAt = Date()
56 }
5718
58 private func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
19 private static func fetchSubdomains(for domain: String, limit: Int) async -> ServiceResult<[DiscoveredSubdomain]> {
5920 var components = URLComponents(string: "https://crt.sh/")!
6021 components.queryItems = [
6122 URLQueryItem(name: "q", value: "%.\(domain)"),
@@ -81,7 +42,7 @@ private actor SubdomainDiscoveryCache {
8142 }
8243 }
8344
84 private func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
45 private static func parseSubdomains(from entries: [CRTShEntry], domain: String, limit: Int) -> [DiscoveredSubdomain] {
8546 var seen = Set<String>()
8647 var results: [DiscoveredSubdomain] = []
8748
DomainDigCLI.swift +26 −6
@@ -14,19 +14,39 @@ struct DomainDigCLI {
1414 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
1515 let domains = arguments.filter { !$0.hasPrefix("-") }
1616
17 guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
17 let requestedDomains = domains
18 .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
19 .filter { !$0.isEmpty }
20
21 guard !requestedDomains.isEmpty else {
1822 fputs("usage: domaindig <domain> [--json]\n", stderr)
1923 Foundation.exit(1)
2024 }
2125
2226 let inspectionService = DomainInspectionService()
23 let report = await inspectionService.inspect(domain: domain)
27 var reports: [DomainReport] = []
28 var seen = Set<String>()
29
30 for domain in requestedDomains {
31 let normalizedDomain = domain.lowercased()
32 guard seen.insert(normalizedDomain).inserted else { continue }
33 reports.append(await inspectionService.inspect(domain: domain))
34 }
2435
2536 do {
26 let data = try DomainReportExporter.data(
27 for: report,
28 format: wantsJSON ? .json : .text
29 )
37 let data: Data
38 if reports.count == 1, let report = reports.first {
39 data = try DomainReportExporter.data(
40 for: report,
41 format: wantsJSON ? .json : .text
42 )
43 } else {
44 data = try DomainReportExporter.data(
45 for: reports,
46 format: wantsJSON ? .json : .text,
47 title: "DomainDig Batch Report"
48 )
49 }
3050 FileHandle.standardOutput.write(data)
3151 if data.last != 0x0A {
3252 FileHandle.standardOutput.write(Data([0x0A]))
DomainInspectionService.swift +231 −54
@@ -2,89 +2,155 @@ import Foundation
22
33struct DomainInspectionService {
44 private let reportBuilder = DomainReportBuilder()
5 private let runtime: LookupRuntime
56
6 func inspect(domain: String) async -> DomainReport {
7 let snapshot = await inspectSnapshot(domain: domain)
7 init(runtime: LookupRuntime = .shared) {
8 self.runtime = runtime
9 }
10
11 func inspect(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> DomainReport {
12 let snapshot = await inspectSnapshot(domain: domain, previousSnapshot: previousSnapshot)
813 return reportBuilder.build(from: snapshot)
914 }
1015
11 func inspectSnapshot(domain: String) async -> LookupSnapshot {
16 func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot {
1217 let normalizedDomain = normalize(domain)
1318 let startedAt = Date()
1419 let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
1520 let resolverURLString = DNSLookupService.currentResolverURLString()
21 var cachedSections = Set<LookupSectionKind>()
22 var sectionSources: [LookupResultSource] = []
23
24 async let dnsFetch = runtime.dns(domain: normalizedDomain)
25 async let availabilityFetch = runtime.availability(domain: normalizedDomain)
26 async let sslFetch = runtime.ssl(domain: normalizedDomain)
27 async let hstsFetch = runtime.hsts(domain: normalizedDomain)
28 async let httpFetch = runtime.http(domain: normalizedDomain)
29 async let reachabilityFetch = runtime.reachability(domain: normalizedDomain)
30 async let ownershipFetch = runtime.ownership(domain: normalizedDomain)
31 async let redirectFetch = runtime.redirectChain(domain: normalizedDomain)
32 async let subdomainFetch = runtime.subdomains(domain: normalizedDomain)
33 async let portScanFetch = runtime.portScan(domain: normalizedDomain)
34
35 let resolvedDNS = await dnsFetch
36 let dnsResult = normalizeErrors(in: resolvedDNS.value)
37 track(.dns, source: resolvedDNS.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
38
39 let availability = await availabilityFetch
40 track(.availability, source: availability.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
41
42 let resolvedSSL = await sslFetch
43 let sslResult = normalizeErrors(in: resolvedSSL.value)
44 track(.ssl, source: resolvedSSL.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
45
46 let hsts = await hstsFetch
47 track(.hsts, source: hsts.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
1648
17 async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain)
18 async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain)
19 async let sslResult = SSLCheckService.check(domain: normalizedDomain)
20 async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain)
21 async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain)
22 async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain)
23 async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain)
24 async let redirectResult = RedirectChainService.trace(domain: normalizedDomain)
25 async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain)
26 async let portScanResult = PortScanService.scanAll(domain: normalizedDomain)
27
28 let resolvedDNS = await dnsResult
29 let availability = await availabilityResult
30 let resolvedSSL = await sslResult
31 let hsts = await hstsResult
32 let http = await httpResult
33 let reachability = await reachabilityResult
34 let resolvedOwnership = await ownershipResult
35 let redirects = await redirectResult
36 let resolvedSubdomains = await subdomainResult
37 let ports = await portScanResult
38
39 let dnsSections = mapServiceResult(resolvedDNS, emptyValue: [])
40 let sslInfo = mapOptionalValueServiceResult(resolvedSSL)
41 let httpHeadersResult = mapHTTPResult(http)
42 let reachabilityResultValue = mapServiceResult(reachability, emptyValue: [])
43 let redirectChain = mapServiceResult(redirects, emptyValue: [])
44 let ownership = mapOptionalValueServiceResult(resolvedOwnership)
45 let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: [])
46 let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain)
49 let http = await httpFetch
50 let httpResult = normalizeErrors(in: http.value)
51 track(.httpHeaders, source: http.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
52
53 let reachability = await reachabilityFetch
54 let reachabilityResult = normalizeErrors(in: reachability.value)
55 track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
56
57 let resolvedOwnership = await ownershipFetch
58 let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
59 track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
60
61 let redirects = await redirectFetch
62 let redirectResult = normalizeErrors(in: redirects.value)
63 track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
64
65 let resolvedSubdomains = await subdomainFetch
66 let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
67 track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
68
69 let ports = await portScanFetch
70 let portScanResult = normalizeErrors(in: ports.value)
71 track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
72
73 let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
74 let sslInfo = mapOptionalValueServiceResult(sslResult)
75 let httpHeadersResult = mapHTTPResult(httpResult)
76 let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
77 let redirectChain = mapServiceResult(redirectResult, emptyValue: [])
78 let ownership = mapOptionalValueServiceResult(ownershipResult)
79 let subdomains = mapServiceResult(subdomainResult, emptyValue: [])
80 let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain)
4781
4882 let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
4983 let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
84 let canReuseDNSDependents = canReuseDependentSections(from: previousSnapshot, dnsSections: dnsSections.value)
85 let canReuseIPDependents = canReuseIPBasedSections(from: previousSnapshot, primaryIP: primaryIP)
5086
51 async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords)
52 async let suggestions = availability.status == .registered
53 ? DomainAvailabilityService.suggestions(for: normalizedDomain)
54 : []
87 let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>>
88 if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity {
89 emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached)
90 } else if canReuseDNSDependents, let previousSnapshot, let error = previousSnapshot.emailSecurityError {
91 emailOutcome = CachedLookupResult(value: .error(error), source: .cached)
92 } else {
93 emailOutcome = await runtime.email(domain: normalizedDomain, txtRecords: txtRecords)
94 }
95 track(.emailSecurity, source: emailOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
5596
56 let resolvedEmail = await emailResult
57 let resolvedSuggestions = await suggestions
97 let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]>
98 if availability.value.status == .registered {
99 suggestionsOutcome = await runtime.suggestions(domain: normalizedDomain)
100 track(.suggestions, source: suggestionsOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
101 } else {
102 suggestionsOutcome = CachedLookupResult(value: [], source: .live)
103 }
58104
59 let ptrResult: ServiceResult<String>?
60 let geoResult: ServiceResult<IPGeolocation>?
105 let ptrOutcome: CachedLookupResult<ServiceResult<String>>?
106 let geoOutcome: CachedLookupResult<ServiceResult<IPGeolocation>>?
61107 if let primaryIP {
62 ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
63 geoResult = await IPGeolocationService.lookup(ip: primaryIP)
108 if canReuseIPDependents, let previousSnapshot, let ptrRecord = previousSnapshot.ptrRecord {
109 ptrOutcome = CachedLookupResult(value: .success(ptrRecord), source: .cached)
110 } else if canReuseIPDependents, let previousSnapshot, let ptrError = previousSnapshot.ptrError {
111 ptrOutcome = CachedLookupResult(value: .error(ptrError), source: .cached)
112 } else {
113 ptrOutcome = await runtime.ptr(ip: primaryIP, resolverURLString: resolverURLString)
114 }
115
116 if canReuseIPDependents, let previousSnapshot, let ipGeolocation = previousSnapshot.ipGeolocation {
117 geoOutcome = CachedLookupResult(value: .success(ipGeolocation), source: .cached)
118 } else if canReuseIPDependents, let previousSnapshot, let ipGeolocationError = previousSnapshot.ipGeolocationError {
119 geoOutcome = CachedLookupResult(value: .error(ipGeolocationError), source: .cached)
120 } else {
121 geoOutcome = await runtime.ipGeolocation(ip: primaryIP)
122 }
123
124 if let ptrOutcome {
125 track(.ptr, source: ptrOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
126 }
127 if let geoOutcome {
128 track(.ipGeolocation, source: geoOutcome.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
129 }
64130 } else {
65 ptrResult = nil
66 geoResult = nil
131 ptrOutcome = nil
132 geoOutcome = nil
67133 }
68134
69 let emailSecurity = mapOptionalValueServiceResult(resolvedEmail)
70 let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available")
71 let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available")
135 let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value))
136 let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
137 let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
72138
73139 return LookupSnapshot(
74140 historyEntryID: nil,
75 domain: availability.domain,
141 domain: availability.value.domain,
76142 timestamp: Date(),
77 trackedDomainID: nil,
143 trackedDomainID: previousSnapshot?.trackedDomainID,
78144 resolverDisplayName: resolverDisplayName,
79145 resolverURLString: resolverURLString,
80146 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
81147 dnsSections: dnsSections.value,
82148 dnsError: dnsSections.message,
83 availabilityResult: availability,
84 suggestions: resolvedSuggestions,
149 availabilityResult: availability.value,
150 suggestions: suggestionsOutcome.value,
85151 sslInfo: sslInfo.value,
86152 sslError: sslInfo.message,
87 hstsPreloaded: hsts,
153 hstsPreloaded: hsts.value,
88154 httpHeaders: httpHeadersResult.headers,
89155 httpSecurityGrade: httpHeadersResult.securityGrade,
90156 httpStatusCode: httpHeadersResult.statusCode,
@@ -109,7 +175,9 @@ struct DomainInspectionService {
109175 portScanResults: portScanResults.value,
110176 portScanError: portScanResults.message,
111177 changeSummary: nil,
112 isLive: false
178 resultSource: aggregateSource(sectionSources),
179 cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
180 statusMessage: nil
113181 )
114182 }
115183
@@ -122,6 +190,115 @@ struct DomainInspectionService {
122190 .lowercased() ?? domain.lowercased()
123191 }
124192
193 private func track(
194 _ section: LookupSectionKind,
195 source: LookupResultSource,
196 cachedSections: inout Set<LookupSectionKind>,
197 sectionSources: inout [LookupResultSource]
198 ) {
199 sectionSources.append(source)
200 if source != .live {
201 cachedSections.insert(section)
202 }
203 }
204
205 private func aggregateSource(_ sectionSources: [LookupResultSource]) -> LookupResultSource {
206 let normalizedSources = sectionSources.map { source -> LookupResultSource in
207 source == .mixed ? .cached : source
208 }
209
210 let hasLive = normalizedSources.contains(.live)
211 let hasCached = normalizedSources.contains(.cached)
212
213 switch (hasLive, hasCached) {
214 case (true, true):
215 return .mixed
216 case (false, true):
217 return .cached
218 default:
219 return .live
220 }
221 }
222
223 private func canReuseDependentSections(from previousSnapshot: LookupSnapshot?, dnsSections: [DNSSection]) -> Bool {
224 guard let previousSnapshot else { return false }
225 return dnsSignature(for: previousSnapshot.dnsSections) == dnsSignature(for: dnsSections)
226 }
227
228 private func canReuseIPBasedSections(from previousSnapshot: LookupSnapshot?, primaryIP: String?) -> Bool {
229 guard let previousSnapshot else { return false }
230 let previousIP = previousSnapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
231 return primaryIP == previousIP
232 }
233
234 private func dnsSignature(for sections: [DNSSection]) -> String {
235 sections
236 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
237 .map { section in
238 let records = section.records
239 .sorted { $0.value < $1.value }
240 .map { "\($0.value)|\($0.ttl)" }
241 .joined(separator: ",")
242 let wildcardRecords = section.wildcardRecords
243 .sorted { $0.value < $1.value }
244 .map { "\($0.value)|\($0.ttl)" }
245 .joined(separator: ",")
246 return [
247 section.recordType.rawValue,
248 records,
249 wildcardRecords,
250 section.dnssecSigned.map { $0 ? "signed" : "unsigned" } ?? "unknown",
251 section.error ?? ""
252 ].joined(separator: "#")
253 }
254 .joined(separator: "||")
255 }
256
257 private func normalizeErrors<Value>(in result: ServiceResult<Value>) -> ServiceResult<Value> {
258 switch result {
259 case let .success(value):
260 return .success(value)
261 case let .empty(message):
262 return .empty(message)
263 case let .error(message):
264 return .error(classifiedMessage(from: message))
265 }
266 }
267
268 private func classifiedMessage(from message: String) -> String {
269 let normalizedMessage = message.trimmingCharacters(in: .whitespacesAndNewlines)
270 let lowercasedMessage = normalizedMessage.lowercased()
271
272 if lowercasedMessage.hasPrefix("network error:")
273 || lowercasedMessage.hasPrefix("timeout:")
274 || lowercasedMessage.hasPrefix("rate limit:")
275 || lowercasedMessage.hasPrefix("parsing error:") {
276 return normalizedMessage
277 }
278
279 if lowercasedMessage.contains("timed out") {
280 return "Timeout: Request timed out"
281 }
282 if lowercasedMessage.contains("429")
283 || lowercasedMessage.contains("too many requests")
284 || lowercasedMessage.contains("rate limit") {
285 return "Rate limit: Try again shortly"
286 }
287 if lowercasedMessage.contains("cannot parse")
288 || lowercasedMessage.contains("decoding")
289 || lowercasedMessage.contains("json") {
290 return "Parsing error: Invalid server response"
291 }
292 if lowercasedMessage.contains("offline")
293 || lowercasedMessage.contains("internet connection")
294 || lowercasedMessage.contains("not connected")
295 || lowercasedMessage.contains("network connection") {
296 return "Network error: Offline"
297 }
298
299 return "Network error: \(normalizedMessage)"
300 }
301
125302 private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
126303 switch result {
127304 case let .success(value):
DomainReportBuilder.swift +2
@@ -3,6 +3,7 @@ import Foundation
33struct DomainReport: Codable {
44 let domain: String
55 let timestamp: Date
6 let resultSource: LookupResultSource
67 let availability: DomainAvailabilityStatus
78 let ownership: DomainOwnership?
89 let dns: DNSResultSummary
@@ -70,6 +71,7 @@ struct DomainReportBuilder {
7071 return DomainReport(
7172 domain: snapshot.domain,
7273 timestamp: snapshot.timestamp,
74 resultSource: snapshot.resultSource,
7375 availability: snapshot.availabilityResult?.status ?? .unknown,
7476 ownership: snapshot.ownership,
7577 dns: DNSResultSummary(
DomainReportExporter.swift +3
@@ -36,6 +36,7 @@ enum DomainReportExporter {
3636 "DomainDig Report",
3737 "Domain: \(report.domain)",
3838 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "Source: \(report.resultSource.label)",
3940 "Availability: \(availabilityLabel(report.availability))"
4041 ]
4142
@@ -212,6 +213,7 @@ enum DomainReportExporter {
212213 let headers = [
213214 "domain",
214215 "timestamp",
216 "result_source",
215217 "availability",
216218 "registrar",
217219 "ownership_expires",
@@ -247,6 +249,7 @@ enum DomainReportExporter {
247249 return [
248250 report.domain,
249251 csvDateFormatter.string(from: report.timestamp),
252 report.resultSource.rawValue,
250253 availabilityLabel(report.availability),
251254 report.ownership?.registrar ?? "",
252255 expirationDate,
LookupSnapshot.swift +10 −2
@@ -39,7 +39,13 @@ struct LookupSnapshot {
3939 let portScanResults: [PortScanResult]
4040 let portScanError: String?
4141 let changeSummary: DomainChangeSummary?
42 let isLive: Bool
42 let resultSource: LookupResultSource
43 let cachedSections: [LookupSectionKind]
44 let statusMessage: String?
45
46 var isLive: Bool {
47 resultSource == .live
48 }
4349}
4450
4551extension HistoryEntry {
@@ -83,7 +89,9 @@ extension HistoryEntry {
8389 portScanResults: portScanResults,
8490 portScanError: portScanError,
8591 changeSummary: changeSummary,
86 isLive: false
92 resultSource: .snapshot,
93 cachedSections: [],
94 statusMessage: nil
8795 )
8896 }
8997}