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 @@
12 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; }; 12 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
13 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; }; 13 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
14 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; }; 14 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 */; };
15/* End PBXBuildFile section */ 19/* End PBXBuildFile section */
16 20
17/* Begin PBXFileReference section */ 21/* Begin PBXFileReference section */
18 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 22 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; };
20 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; }; 23 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; };
21 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; }; 24 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
22 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; }; 25 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; };
23 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; }; 26 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
24 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; }; 27 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; };
25/* End PBXFileReference section */ 29/* End PBXFileReference section */
26 30
27/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 31/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
@@ -33,11 +37,9 @@
33 ); 37 );
34 target = 8B7800682F6090E300933221 /* DomainDig */; 38 target = 8B7800682F6090E300933221 /* DomainDig */;
35 }; 39 };
36/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
37 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = { 40 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = {
38 isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 41 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
39 membershipExceptions = ( 42 membershipExceptions = (
40 Assets.xcassets,
41 BatchResultsView.swift, 43 BatchResultsView.swift,
42 BatchSweepSummaryView.swift, 44 BatchSweepSummaryView.swift,
43 ContentView.swift, 45 ContentView.swift,
@@ -45,13 +47,13 @@
45 DomainViewModel.swift, 47 DomainViewModel.swift,
46 ExportPresenter.swift, 48 ExportPresenter.swift,
47 HistoryView.swift, 49 HistoryView.swift,
48 Info.plist,
49 LocalNotificationService.swift, 50 LocalNotificationService.swift,
50 SavedDomainsView.swift, 51 SavedDomainsView.swift,
51 WatchlistView.swift, 52 WatchlistView.swift,
52 ); 53 );
53 target = 8BF124FB2F70000100933221 /* DomainDigCLI */; 54 target = 8BF124FB2F70000100933221 /* DomainDigCLI */;
54 }; 55 };
56/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
55 57
56/* Begin PBXFileSystemSynchronizedRootGroup section */ 58/* Begin PBXFileSystemSynchronizedRootGroup section */
57 8B78006B2F6090E300933221 /* DomainDig */ = { 59 8B78006B2F6090E300933221 /* DomainDig */ = {
@@ -132,7 +134,7 @@
132 }; 134 };
133 8BF124FB2F70000100933221 /* DomainDigCLI */ = { 135 8BF124FB2F70000100933221 /* DomainDigCLI */ = {
134 isa = PBXNativeTarget; 136 isa = PBXNativeTarget;
135 buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */; 137 buildConfigurationList = 8BBFF0292F987EF700E8E144 /* Build configuration list */;
136 buildPhases = ( 138 buildPhases = (
137 8BF124FC2F70000100933221 /* Sources */, 139 8BF124FC2F70000100933221 /* Sources */,
138 8BF124FD2F70000100933221 /* Frameworks */, 140 8BF124FD2F70000100933221 /* Frameworks */,
@@ -201,7 +203,6 @@
201 isa = PBXSourcesBuildPhase; 203 isa = PBXSourcesBuildPhase;
202 buildActionMask = 2147483647; 204 buildActionMask = 2147483647;
203 files = ( 205 files = (
204 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
205 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */, 206 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
206 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */, 207 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
207 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */, 208 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
@@ -209,6 +210,18 @@
209 ); 210 );
210 runOnlyForDeploymentPostprocessing = 0; 211 runOnlyForDeploymentPostprocessing = 0;
211 }; 212 };
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 };
212/* End PBXSourcesBuildPhase section */ 225/* End PBXSourcesBuildPhase section */
213 226
214/* Begin XCBuildConfiguration section */ 227/* Begin XCBuildConfiguration section */
@@ -341,7 +354,7 @@
341 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 354 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
342 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 355 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
343 CODE_SIGN_STYLE = Automatic; 356 CODE_SIGN_STYLE = Automatic;
344 CURRENT_PROJECT_VERSION = 17; 357 CURRENT_PROJECT_VERSION = 18;
345 DEVELOPMENT_TEAM = ZCNAX3VL9D; 358 DEVELOPMENT_TEAM = ZCNAX3VL9D;
346 ENABLE_PREVIEWS = YES; 359 ENABLE_PREVIEWS = YES;
347 GENERATE_INFOPLIST_FILE = YES; 360 GENERATE_INFOPLIST_FILE = YES;
@@ -358,7 +371,7 @@
358 "$(inherited)", 371 "$(inherited)",
359 "@executable_path/Frameworks", 372 "@executable_path/Frameworks",
360 ); 373 );
361 MARKETING_VERSION = 2.4.0; 374 MARKETING_VERSION = 2.5.0;
362 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 375 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
363 PRODUCT_NAME = "$(TARGET_NAME)"; 376 PRODUCT_NAME = "$(TARGET_NAME)";
364 STRING_CATALOG_GENERATE_SYMBOLS = YES; 377 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -377,7 +390,7 @@
377 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 390 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
378 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 391 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
379 CODE_SIGN_STYLE = Automatic; 392 CODE_SIGN_STYLE = Automatic;
380 CURRENT_PROJECT_VERSION = 17; 393 CURRENT_PROJECT_VERSION = 18;
381 DEVELOPMENT_TEAM = ZCNAX3VL9D; 394 DEVELOPMENT_TEAM = ZCNAX3VL9D;
382 ENABLE_PREVIEWS = YES; 395 ENABLE_PREVIEWS = YES;
383 GENERATE_INFOPLIST_FILE = YES; 396 GENERATE_INFOPLIST_FILE = YES;
@@ -394,7 +407,7 @@
394 "$(inherited)", 407 "$(inherited)",
395 "@executable_path/Frameworks", 408 "@executable_path/Frameworks",
396 ); 409 );
397 MARKETING_VERSION = 2.4.0; 410 MARKETING_VERSION = 2.5.0;
398 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 411 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
399 PRODUCT_NAME = "$(TARGET_NAME)"; 412 PRODUCT_NAME = "$(TARGET_NAME)";
400 STRING_CATALOG_GENERATE_SYMBOLS = YES; 413 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -407,44 +420,22 @@
407 }; 420 };
408 name = Release; 421 name = Release;
409 }; 422 };
410 8BF125002F70000100933221 /* Debug */ = { 423 8BBFF0272F987E8700E8E144 /* Debug */ = {
411 isa = XCBuildConfiguration; 424 isa = XCBuildConfiguration;
412 buildSettings = { 425 buildSettings = {
413 CODE_SIGNING_ALLOWED = NO; 426 COPY_PHASE_STRIP = NO;
414 CODE_SIGN_STYLE = Automatic; 427 GCC_DYNAMIC_NO_PIC = NO;
415 CURRENT_PROJECT_VERSION = 17; 428 GCC_OPTIMIZATION_LEVEL = 0;
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;
422 PRODUCT_NAME = domaindig; 429 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;
428 }; 430 };
429 name = Debug; 431 name = Debug;
430 }; 432 };
431 8BF125012F70000100933221 /* Release */ = { 433 8BBFF0282F987E8700E8E144 /* Release */ = {
432 isa = XCBuildConfiguration; 434 isa = XCBuildConfiguration;
433 buildSettings = { 435 buildSettings = {
434 CODE_SIGNING_ALLOWED = NO; 436 COPY_PHASE_STRIP = YES;
435 CODE_SIGN_STYLE = Automatic; 437 DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
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;
442 PRODUCT_NAME = domaindig; 438 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;
448 }; 439 };
449 name = Release; 440 name = Release;
450 }; 441 };
@@ -469,6 +460,15 @@
469 defaultConfigurationIsVisible = 0; 460 defaultConfigurationIsVisible = 0;
470 defaultConfigurationName = Release; 461 defaultConfigurationName = Release;
471 }; 462 };
463 8BBFF0292F987EF700E8E144 /* Build configuration list */ = {
464 isa = XCConfigurationList;
465 buildConfigurations = (
466 8BBFF0272F987E8700E8E144 /* Debug */,
467 8BBFF0282F987E8700E8E144 /* Release */,
468 );
469 defaultConfigurationIsVisible = 0;
470 defaultConfigurationName = Release;
471 };
472/* End XCConfigurationList section */ 472/* End XCConfigurationList section */
473 }; 473 };
474 rootObject = 8B7800612F6090E300933221 /* Project object */; 474 rootObject = 8B7800612F6090E300933221 /* Project object */;
DomainDig/BatchResultsView.swift +4 −1
@@ -58,6 +58,9 @@ struct BatchResultRowView: View {
58 .foregroundStyle(.primary) 58 .foregroundStyle(.primary)
59 .lineLimit(1) 59 .lineLimit(1)
60 Spacer(minLength: 8) 60 Spacer(minLength: 8)
61 Text(result.resultSource.label.lowercased())
62 .font(.system(.caption2, design: .monospaced))
63 .foregroundStyle(.secondary)
61 Text(result.quickStatus) 64 Text(result.quickStatus)
62 .font(.system(.caption2, design: .monospaced)) 65 .font(.system(.caption2, design: .monospaced))
63 .foregroundStyle(quickStatusColor) 66 .foregroundStyle(quickStatusColor)
@@ -84,7 +87,7 @@ struct BatchResultRowView: View {
84 if let errorMessage = result.errorMessage { 87 if let errorMessage = result.errorMessage {
85 Text(errorMessage) 88 Text(errorMessage)
86 .font(.system(.caption2, design: .monospaced)) 89 .font(.system(.caption2, design: .monospaced))
87 .foregroundStyle(.red) 90 .foregroundStyle(result.status == .failed ? .red : .secondary)
88 } 91 }
89 } 92 }
90 .frame(maxWidth: .infinity, alignment: .leading) 93 .frame(maxWidth: .infinity, alignment: .leading)
DomainDig/ContentView.swift +50
@@ -30,6 +30,10 @@ struct ContentView: View {
30 } 30 }
31 if viewModel.hasRun { 31 if viewModel.hasRun {
32 actionButtons 32 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 }
33 SummaryView(fields: viewModel.summaryFields) 37 SummaryView(fields: viewModel.summaryFields)
34 .padding(.top, 8) 38 .padding(.top, 8)
35 if let changeSummary = viewModel.currentChangeSummary { 39 if let changeSummary = viewModel.currentChangeSummary {
@@ -494,6 +498,52 @@ struct SummaryView: View {
494 } 498 }
495} 499}
496 500
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
497struct DomainChangeSummaryView: View { 547struct DomainChangeSummaryView: View {
498 let summary: DomainChangeSummary 548 let summary: DomainChangeSummary
499 549
DomainDig/DomainViewModel.swift +217 −50
@@ -184,6 +184,12 @@ final class DomainViewModel {
184 private var activeBatchDomains: [String] = [] 184 private var activeBatchDomains: [String] = []
185 private var lastBatchStartedAt: Date? 185 private var lastBatchStartedAt: Date?
186 private let reportBuilder = DomainReportBuilder() 186 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?
187 193
188 private static let recentSearchesKey = "recentSearches" 194 private static let recentSearchesKey = "recentSearches"
189 private static let maxRecent = 20 195 private static let maxRecent = 20
@@ -355,9 +361,9 @@ final class DomainViewModel {
355 361
356 var currentSnapshot: LookupSnapshot { 362 var currentSnapshot: LookupSnapshot {
357 LookupSnapshot( 363 LookupSnapshot(
358 historyEntryID: nil, 364 historyEntryID: currentHistoryEntryID,
359 domain: searchedDomain, 365 domain: searchedDomain,
360 timestamp: Date(), 366 timestamp: currentSnapshotTimestamp,
361 trackedDomainID: currentTrackedDomain?.id, 367 trackedDomainID: currentTrackedDomain?.id,
362 resolverDisplayName: resolverDisplayName, 368 resolverDisplayName: resolverDisplayName,
363 resolverURLString: resolverURLString, 369 resolverURLString: resolverURLString,
@@ -393,7 +399,9 @@ final class DomainViewModel {
393 portScanResults: allPortScanResults, 399 portScanResults: allPortScanResults,
394 portScanError: combinedPortScanError, 400 portScanError: combinedPortScanError,
395 changeSummary: currentChangeSummary, 401 changeSummary: currentChangeSummary,
396 isLive: true 402 resultSource: currentResultSource,
403 cachedSections: currentCachedSections,
404 statusMessage: currentStatusMessage
397 ) 405 )
398 } 406 }
399 407
@@ -746,51 +754,163 @@ final class DomainViewModel {
746 } 754 }
747 755
748 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? { 756 private func performLookup(domain: String, lookupID: UUID) async -> HistoryEntry? {
749 await withTaskGroup(of: Void.self) { group in 757 let previous = previousSnapshot(
750 group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } 758 for: domain,
751 group.addTask { await self.runAvailability(domain: domain, lookupID: lookupID) } 759 trackedDomainID: currentTrackedDomain?.id,
752 group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } 760 replacingLatest: false
753 group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } 761 )
754 group.addTask { await self.runOwnership(domain: domain, lookupID: lookupID) } 762 let inspectedSnapshot = await inspectionService.inspectSnapshot(domain: domain, previousSnapshot: previous)
755 group.addTask { await self.runSubdomains(domain: domain, lookupID: lookupID) } 763 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil }
756 }
757 764
758 await withTaskGroup(of: Void.self) { group in 765 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previous)
759 group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } 766 applySnapshot(snapshot)
760 group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) } 767 lastLookupDurationMs = snapshot.totalLookupDurationMs
761 group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) } 768 refreshingTrackedDomainID = nil
762 group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) } 769
770 guard snapshot.statusMessage == nil else {
771 return history.first(where: { $0.id == snapshot.historyEntryID })
763 } 772 }
764 773
765 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } 774 return saveHistoryEntry(replaceLatest: false)
775 }
766 776
767 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] 777 private func applySnapshot(_ snapshot: LookupSnapshot) {
768 let primaryIP = primaryIPAddress(from: dnsSections) 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 = []
769 786
770 await withTaskGroup(of: Void.self) { group in 787 dnsSections = snapshot.dnsSections
771 group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) } 788 dnsError = snapshot.dnsError
772 if let primaryIP { 789 availabilityResult = snapshot.availabilityResult
773 group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) } 790 suggestions = snapshot.suggestions
774 group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) } 791 sslInfo = snapshot.sslInfo
775 } else { 792 sslError = snapshot.sslError
776 group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) } 793 hstsPreloaded = snapshot.hstsPreloaded
777 } 794 httpHeaders = snapshot.httpHeaders
778 } 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
779 819
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 }
781 836
782 if availabilityResult?.status == .registered { 837 private static func resolvedSnapshotAfterFallback(
783 await runSuggestions(domain: domain, lookupID: lookupID) 838 _ snapshot: LookupSnapshot,
784 } else { 839 previousSnapshot: LookupSnapshot?
785 suggestions = [] 840 ) -> LookupSnapshot {
786 suggestionsLoading = false 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:")
787 } 905 }
788 906
789 guard !Task.isCancelled, isCurrentLookup(lookupID) else { return nil } 907 let hasMaterialData = !snapshot.dnsSections.isEmpty
790 lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } 908 || !snapshot.httpHeaders.isEmpty
791 let entry = saveHistoryEntry(replaceLatest: false) 909 || snapshot.sslInfo != nil
792 refreshingTrackedDomainID = nil 910 || snapshot.ownership != nil
793 return entry 911 || !snapshot.subdomains.isEmpty
912
913 return failedDueToConnectivity && !hasMaterialData
794 } 914 }
795 915
796 private func runDNS(domain: String, lookupID: UUID) async { 916 private func runDNS(domain: String, lookupID: UUID) async {
@@ -1044,11 +1164,12 @@ final class DomainViewModel {
1044 customPortScanLoading = false 1164 customPortScanLoading = false
1045 } 1165 }
1046 1166
1047 private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { 1167 private static func performBatchLookup(domain: String, previousSnapshot: LookupSnapshot?) async -> BatchLookupPayload? {
1048 guard !Task.isCancelled else { return nil } 1168 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)
1050 guard !Task.isCancelled else { return nil } 1171 guard !Task.isCancelled else { return nil }
1051 return BatchLookupPayload(snapshot: snapshot) 1172 return BatchLookupPayload(snapshot: resolvedSnapshotAfterFallback(snapshot, previousSnapshot: previousSnapshot))
1052 } 1173 }
1053 1174
1054 private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] { 1175 private static func enrichOpenPortBanners(_ results: [PortScanResult], domain: String) async -> [PortScanResult] {
@@ -1141,6 +1262,10 @@ final class DomainViewModel {
1141 portScanError: snapshot.portScanError 1262 portScanError: snapshot.portScanError
1142 ) 1263 )
1143 1264
1265 if updateCurrentState {
1266 currentHistoryEntryID = entry.id
1267 }
1268
1144 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame { 1269 if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(snapshot.domain) == .orderedSame {
1145 history[0] = entry 1270 history[0] = entry
1146 } else { 1271 } else {
@@ -1318,6 +1443,11 @@ final class DomainViewModel {
1318 addRecentSearch(target) 1443 addRecentSearch(target)
1319 searchedDomain = target 1444 searchedDomain = target
1320 hasRun = true 1445 hasRun = true
1446 currentHistoryEntryID = nil
1447 currentSnapshotTimestamp = Date()
1448 currentResultSource = .live
1449 currentCachedSections = []
1450 currentStatusMessage = nil
1321 currentDiffSections = [] 1451 currentDiffSections = []
1322 currentChangeSummary = nil 1452 currentChangeSummary = nil
1323 ownershipDiff = [] 1453 ownershipDiff = []
@@ -1424,9 +1554,10 @@ final class DomainViewModel {
1424 refreshingTrackedDomainID = trackedDomain(for: domain)?.id 1554 refreshingTrackedDomainID = trackedDomain(for: domain)?.id
1425 } 1555 }
1426 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil) 1556 updateBatchResult(domain: domain, status: .running, quickStatus: "Running", entry: nil, errorMessage: nil)
1557 let previousSnapshot = previousSnapshot(for: domain, trackedDomainID: trackedDomain(for: domain)?.id, replacingLatest: false)
1427 1558
1428 group.addTask { [domain] in 1559 group.addTask { [domain, previousSnapshot] in
1429 let payload = await Self.performBatchLookup(domain: domain) 1560 let payload = await Self.performBatchLookup(domain: domain, previousSnapshot: previousSnapshot)
1430 return (domain, payload) 1561 return (domain, payload)
1431 } 1562 }
1432 } 1563 }
@@ -1441,13 +1572,21 @@ final class DomainViewModel {
1441 status: .failed, 1572 status: .failed,
1442 quickStatus: "Failed", 1573 quickStatus: "Failed",
1443 entry: nil, 1574 entry: nil,
1575 resultSource: .live,
1444 errorMessage: "Lookup cancelled" 1576 errorMessage: "Lookup cancelled"
1445 ) 1577 )
1446 batchCompletedCount += 1 1578 batchCompletedCount += 1
1447 return 1579 return
1448 } 1580 }
1449 1581
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 }
1451 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot) 1590 let certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: payload.snapshot)
1452 let quickStatus: String 1591 let quickStatus: String
1453 if entry?.changeSummary?.hasChanges == true { 1592 if entry?.changeSummary?.hasChanges == true {
@@ -1463,7 +1602,8 @@ final class DomainViewModel {
1463 status: .completed, 1602 status: .completed,
1464 quickStatus: quickStatus, 1603 quickStatus: quickStatus,
1465 entry: entry, 1604 entry: entry,
1466 errorMessage: nil 1605 resultSource: payload.snapshot.resultSource,
1606 errorMessage: payload.snapshot.statusMessage
1467 ) 1607 )
1468 batchCompletedCount += 1 1608 batchCompletedCount += 1
1469 } 1609 }
@@ -1503,6 +1643,7 @@ final class DomainViewModel {
1503 status: BatchLookupStatus, 1643 status: BatchLookupStatus,
1504 quickStatus: String, 1644 quickStatus: String,
1505 entry: HistoryEntry?, 1645 entry: HistoryEntry?,
1646 resultSource: LookupResultSource = .live,
1506 errorMessage: String? 1647 errorMessage: String?
1507 ) { 1648 ) {
1508 guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else { 1649 guard let index = batchResults.firstIndex(where: { $0.domain.caseInsensitiveCompare(domain) == .orderedSame }) else {
@@ -1513,6 +1654,7 @@ final class DomainViewModel {
1513 id: batchResults[index].id, 1654 id: batchResults[index].id,
1514 domain: domain, 1655 domain: domain,
1515 historyEntryID: entry?.id, 1656 historyEntryID: entry?.id,
1657 resultSource: resultSource,
1516 availability: entry?.availabilityResult?.status, 1658 availability: entry?.availabilityResult?.status,
1517 primaryIP: entry?.primaryIP, 1659 primaryIP: entry?.primaryIP,
1518 quickStatus: quickStatus, 1660 quickStatus: quickStatus,
@@ -1573,6 +1715,11 @@ final class DomainViewModel {
1573 customPortResults = [] 1715 customPortResults = []
1574 customPortScanError = nil 1716 customPortScanError = nil
1575 customPortScanLoading = false 1717 customPortScanLoading = false
1718 currentHistoryEntryID = nil
1719 currentSnapshotTimestamp = Date()
1720 currentResultSource = .live
1721 currentCachedSections = []
1722 currentStatusMessage = nil
1576 } 1723 }
1577 1724
1578 private func setAllLoadingStates(_ loading: Bool) { 1725 private func setAllLoadingStates(_ loading: Bool) {
@@ -1715,7 +1862,9 @@ final class DomainViewModel {
1715 portScanResults: [], 1862 portScanResults: [],
1716 portScanError: nil, 1863 portScanError: nil,
1717 changeSummary: trackedDomain.lastChangeSummary, 1864 changeSummary: trackedDomain.lastChangeSummary,
1718 isLive: false 1865 resultSource: .snapshot,
1866 cachedSections: [],
1867 statusMessage: nil
1719 ) 1868 )
1720 } 1869 }
1721 1870
@@ -1801,7 +1950,8 @@ final class DomainViewModel {
1801 SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), 1950 SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary),
1802 SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), 1951 SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)),
1803 SummaryFieldViewData(label: "Certificate", value: certificateStatusLabel(from: snapshot), tone: certificateStatusTone(from: snapshot)), 1952 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))
1805 ] 1955 ]
1806 } 1956 }
1807 1957
@@ -1809,7 +1959,7 @@ final class DomainViewModel {
1809 var rows = [ 1959 var rows = [
1810 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), 1960 InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary),
1811 InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), 1961 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)),
1813 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) 1963 InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary)
1814 ] 1964 ]
1815 rows.insert( 1965 rows.insert(
@@ -2083,7 +2233,7 @@ final class DomainViewModel {
2083 "DomainDig Export", 2233 "DomainDig Export",
2084 "Domain: \(snapshot.domain)", 2234 "Domain: \(snapshot.domain)",
2085 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", 2235 "Date: \(exportDateFormatter.string(from: snapshot.timestamp))",
2086 "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", 2236 "Mode: \(snapshot.statusMessage ?? snapshot.resultSource.label)",
2087 "Resolver: \(snapshot.resolverDisplayName)", 2237 "Resolver: \(snapshot.resolverDisplayName)",
2088 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))", 2238 "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))",
2089 "Tracked: \(trackedDomain == nil ? "No" : "Yes")" 2239 "Tracked: \(trackedDomain == nil ? "No" : "Yes")"
@@ -2417,6 +2567,23 @@ final class DomainViewModel {
2417 } 2567 }
2418 } 2568 }
2419 2569
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
2420 private static func securityGradeTone(_ grade: String) -> ResultTone { 2587 private static func securityGradeTone(_ grade: String) -> ResultTone {
2421 switch grade { 2588 switch grade {
2422 case "A", "B": 2589 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> {
6 case error(String) 6 case error(String)
7} 7}
8 8
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
9enum DomainAvailabilityStatus: String, Codable { 46enum DomainAvailabilityStatus: String, Codable {
10 case available 47 case available
11 case registered 48 case registered
@@ -134,6 +171,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
134 let id: UUID 171 let id: UUID
135 let domain: String 172 let domain: String
136 let historyEntryID: UUID? 173 let historyEntryID: UUID?
174 let resultSource: LookupResultSource
137 let availability: DomainAvailabilityStatus? 175 let availability: DomainAvailabilityStatus?
138 let primaryIP: String? 176 let primaryIP: String?
139 let quickStatus: String 177 let quickStatus: String
@@ -148,6 +186,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
148 id: UUID = UUID(), 186 id: UUID = UUID(),
149 domain: String, 187 domain: String,
150 historyEntryID: UUID?, 188 historyEntryID: UUID?,
189 resultSource: LookupResultSource = .live,
151 availability: DomainAvailabilityStatus?, 190 availability: DomainAvailabilityStatus?,
152 primaryIP: String?, 191 primaryIP: String?,
153 quickStatus: String, 192 quickStatus: String,
@@ -161,6 +200,7 @@ struct BatchLookupResult: Identifiable, Codable, Equatable {
161 self.id = id 200 self.id = id
162 self.domain = domain 201 self.domain = domain
163 self.historyEntryID = historyEntryID 202 self.historyEntryID = historyEntryID
203 self.resultSource = resultSource
164 self.availability = availability 204 self.availability = availability
165 self.primaryIP = primaryIP 205 self.primaryIP = primaryIP
166 self.quickStatus = quickStatus 206 self.quickStatus = quickStatus
DomainDig/RDAPService.swift +2 −29
@@ -5,7 +5,7 @@ enum RDAPService {
5 let normalizedDomain = normalize(domain) 5 let normalizedDomain = normalize(domain)
6 guard !normalizedDomain.isEmpty else { return nil } 6 guard !normalizedDomain.isEmpty else { return nil }
7 7
8 switch await cache.response(for: normalizedDomain) { 8 switch await fetchRDAPResponse(for: normalizedDomain) {
9 case let .success(response): 9 case let .success(response):
10 return response.isDomainRecord ? .registered : nil 10 return response.isDomainRecord ? .registered : nil
11 case .empty: 11 case .empty:
@@ -21,7 +21,7 @@ enum RDAPService {
21 return .empty("Unavailable") 21 return .empty("Unavailable")
22 } 22 }
23 23
24 switch await cache.response(for: normalizedDomain) { 24 switch await fetchRDAPResponse(for: normalizedDomain) {
25 case let .success(response): 25 case let .success(response):
26 let ownership = DomainOwnership( 26 let ownership = DomainOwnership(
27 registrar: response.registrarName, 27 registrar: response.registrarName,
@@ -39,8 +39,6 @@ enum RDAPService {
39 } 39 }
40 } 40 }
41 41
42 private static let cache = RDAPCache()
43
44 private static func normalize(_ domain: String) -> String { 42 private static func normalize(_ domain: String) -> String {
45 domain 43 domain
46 .trimmingCharacters(in: .whitespacesAndNewlines) 44 .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -48,31 +46,6 @@ enum RDAPService {
48 } 46 }
49} 47}
50 48
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
76private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> { 49private func fetchRDAPResponse(for domain: String) async -> ServiceResult<RDAPDomainResponse> {
77 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else { 50 guard let url = URL(string: "https://rdap.org/domain/\(domain)") else {
78 return .error("Unavailable") 51 return .error("Unavailable")
DomainDig/SubdomainDiscoveryService.swift +3 −42
@@ -7,55 +7,16 @@ enum SubdomainDiscoveryService {
7 return .empty("No passive subdomains found") 7 return .empty("No passive subdomains found")
8 } 8 }
9 9
10 return await cache.subdomains(for: normalizedDomain, limit: limit) 10 return await fetchSubdomains(for: normalizedDomain, limit: limit)
11 } 11 }
12 12
13 private static let cache = SubdomainDiscoveryCache()
14
15 private static func normalize(_ domain: String) -> String { 13 private static func normalize(_ domain: String) -> String {
16 domain 14 domain
17 .trimmingCharacters(in: .whitespacesAndNewlines) 15 .trimmingCharacters(in: .whitespacesAndNewlines)
18 .lowercased() 16 .lowercased()
19 } 17 }
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 }
57 18
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]> {
59 var components = URLComponents(string: "https://crt.sh/")! 20 var components = URLComponents(string: "https://crt.sh/")!
60 components.queryItems = [ 21 components.queryItems = [
61 URLQueryItem(name: "q", value: "%.\(domain)"), 22 URLQueryItem(name: "q", value: "%.\(domain)"),
@@ -81,7 +42,7 @@ private actor SubdomainDiscoveryCache {
81 } 42 }
82 } 43 }
83 44
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] {
85 var seen = Set<String>() 46 var seen = Set<String>()
86 var results: [DiscoveredSubdomain] = [] 47 var results: [DiscoveredSubdomain] = []
87 48
DomainDigCLI.swift +26 −6
@@ -14,19 +14,39 @@ struct DomainDigCLI {
14 let wantsJSON = arguments.contains("--json") || arguments.contains("-j") 14 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
15 let domains = arguments.filter { !$0.hasPrefix("-") } 15 let domains = arguments.filter { !$0.hasPrefix("-") }
16 16
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 {
18 fputs("usage: domaindig <domain> [--json]\n", stderr) 22 fputs("usage: domaindig <domain> [--json]\n", stderr)
19 Foundation.exit(1) 23 Foundation.exit(1)
20 } 24 }
21 25
22 let inspectionService = DomainInspectionService() 26 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 }
24 35
25 do { 36 do {
26 let data = try DomainReportExporter.data( 37 let data: Data
27 for: report, 38 if reports.count == 1, let report = reports.first {
28 format: wantsJSON ? .json : .text 39 data = try DomainReportExporter.data(
29 ) 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 }
30 FileHandle.standardOutput.write(data) 50 FileHandle.standardOutput.write(data)
31 if data.last != 0x0A { 51 if data.last != 0x0A {
32 FileHandle.standardOutput.write(Data([0x0A])) 52 FileHandle.standardOutput.write(Data([0x0A]))
DomainInspectionService.swift +231 −54
@@ -2,89 +2,155 @@ import Foundation
2 2
3struct DomainInspectionService { 3struct DomainInspectionService {
4 private let reportBuilder = DomainReportBuilder() 4 private let reportBuilder = DomainReportBuilder()
5 private let runtime: LookupRuntime
5 6
6 func inspect(domain: String) async -> DomainReport { 7 init(runtime: LookupRuntime = .shared) {
7 let snapshot = await inspectSnapshot(domain: domain) 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)
8 return reportBuilder.build(from: snapshot) 13 return reportBuilder.build(from: snapshot)
9 } 14 }
10 15
11 func inspectSnapshot(domain: String) async -> LookupSnapshot { 16 func inspectSnapshot(domain: String, previousSnapshot: LookupSnapshot? = nil) async -> LookupSnapshot {
12 let normalizedDomain = normalize(domain) 17 let normalizedDomain = normalize(domain)
13 let startedAt = Date() 18 let startedAt = Date()
14 let resolverDisplayName = DNSLookupService.currentResolverDisplayName() 19 let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
15 let resolverURLString = DNSLookupService.currentResolverURLString() 20 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)
16 48
17 async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain) 49 let http = await httpFetch
18 async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain) 50 let httpResult = normalizeErrors(in: http.value)
19 async let sslResult = SSLCheckService.check(domain: normalizedDomain) 51 track(.httpHeaders, source: http.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
20 async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain) 52
21 async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain) 53 let reachability = await reachabilityFetch
22 async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain) 54 let reachabilityResult = normalizeErrors(in: reachability.value)
23 async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain) 55 track(.reachability, source: reachability.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
24 async let redirectResult = RedirectChainService.trace(domain: normalizedDomain) 56
25 async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain) 57 let resolvedOwnership = await ownershipFetch
26 async let portScanResult = PortScanService.scanAll(domain: normalizedDomain) 58 let ownershipResult = normalizeErrors(in: resolvedOwnership.value)
27 59 track(.ownership, source: resolvedOwnership.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
28 let resolvedDNS = await dnsResult 60
29 let availability = await availabilityResult 61 let redirects = await redirectFetch
30 let resolvedSSL = await sslResult 62 let redirectResult = normalizeErrors(in: redirects.value)
31 let hsts = await hstsResult 63 track(.redirectChain, source: redirects.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
32 let http = await httpResult 64
33 let reachability = await reachabilityResult 65 let resolvedSubdomains = await subdomainFetch
34 let resolvedOwnership = await ownershipResult 66 let subdomainResult = normalizeErrors(in: resolvedSubdomains.value)
35 let redirects = await redirectResult 67 track(.subdomains, source: resolvedSubdomains.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
36 let resolvedSubdomains = await subdomainResult 68
37 let ports = await portScanResult 69 let ports = await portScanFetch
38 70 let portScanResult = normalizeErrors(in: ports.value)
39 let dnsSections = mapServiceResult(resolvedDNS, emptyValue: []) 71 track(.portScan, source: ports.source, cachedSections: &cachedSections, sectionSources: &sectionSources)
40 let sslInfo = mapOptionalValueServiceResult(resolvedSSL) 72
41 let httpHeadersResult = mapHTTPResult(http) 73 let dnsSections = mapServiceResult(dnsResult, emptyValue: [])
42 let reachabilityResultValue = mapServiceResult(reachability, emptyValue: []) 74 let sslInfo = mapOptionalValueServiceResult(sslResult)
43 let redirectChain = mapServiceResult(redirects, emptyValue: []) 75 let httpHeadersResult = mapHTTPResult(httpResult)
44 let ownership = mapOptionalValueServiceResult(resolvedOwnership) 76 let reachabilityResultValue = mapServiceResult(reachabilityResult, emptyValue: [])
45 let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: []) 77 let redirectChain = mapServiceResult(redirectResult, emptyValue: [])
46 let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain) 78 let ownership = mapOptionalValueServiceResult(ownershipResult)
79 let subdomains = mapServiceResult(subdomainResult, emptyValue: [])
80 let portScanResults = await mapPortScanResult(portScanResult, domain: normalizedDomain)
47 81
48 let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? [] 82 let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
49 let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value 83 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)
50 86
51 async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords) 87 let emailOutcome: CachedLookupResult<ServiceResult<EmailSecurityResult>>
52 async let suggestions = availability.status == .registered 88 if canReuseDNSDependents, let previousSnapshot, let emailSecurity = previousSnapshot.emailSecurity {
53 ? DomainAvailabilityService.suggestions(for: normalizedDomain) 89 emailOutcome = CachedLookupResult(value: .success(emailSecurity), source: .cached)
54 : [] 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)
55 96
56 let resolvedEmail = await emailResult 97 let suggestionsOutcome: CachedLookupResult<[DomainSuggestionResult]>
57 let resolvedSuggestions = await suggestions 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 }
58 104
59 let ptrResult: ServiceResult<String>? 105 let ptrOutcome: CachedLookupResult<ServiceResult<String>>?
60 let geoResult: ServiceResult<IPGeolocation>? 106 let geoOutcome: CachedLookupResult<ServiceResult<IPGeolocation>>?
61 if let primaryIP { 107 if let primaryIP {
62 ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString) 108 if canReuseIPDependents, let previousSnapshot, let ptrRecord = previousSnapshot.ptrRecord {
63 geoResult = await IPGeolocationService.lookup(ip: primaryIP) 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 }
64 } else { 130 } else {
65 ptrResult = nil 131 ptrOutcome = nil
66 geoResult = nil 132 geoOutcome = nil
67 } 133 }
68 134
69 let emailSecurity = mapOptionalValueServiceResult(resolvedEmail) 135 let emailSecurity = mapOptionalValueServiceResult(normalizeErrors(in: emailOutcome.value))
70 let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available") 136 let ptrRecord = mapOptionalServiceResult(ptrOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
71 let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available") 137 let geolocation = mapOptionalServiceResult(geoOutcome.map { normalizeErrors(in: $0.value) }, missingMessage: "No A record available")
72 138
73 return LookupSnapshot( 139 return LookupSnapshot(
74 historyEntryID: nil, 140 historyEntryID: nil,
75 domain: availability.domain, 141 domain: availability.value.domain,
76 timestamp: Date(), 142 timestamp: Date(),
77 trackedDomainID: nil, 143 trackedDomainID: previousSnapshot?.trackedDomainID,
78 resolverDisplayName: resolverDisplayName, 144 resolverDisplayName: resolverDisplayName,
79 resolverURLString: resolverURLString, 145 resolverURLString: resolverURLString,
80 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000), 146 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
81 dnsSections: dnsSections.value, 147 dnsSections: dnsSections.value,
82 dnsError: dnsSections.message, 148 dnsError: dnsSections.message,
83 availabilityResult: availability, 149 availabilityResult: availability.value,
84 suggestions: resolvedSuggestions, 150 suggestions: suggestionsOutcome.value,
85 sslInfo: sslInfo.value, 151 sslInfo: sslInfo.value,
86 sslError: sslInfo.message, 152 sslError: sslInfo.message,
87 hstsPreloaded: hsts, 153 hstsPreloaded: hsts.value,
88 httpHeaders: httpHeadersResult.headers, 154 httpHeaders: httpHeadersResult.headers,
89 httpSecurityGrade: httpHeadersResult.securityGrade, 155 httpSecurityGrade: httpHeadersResult.securityGrade,
90 httpStatusCode: httpHeadersResult.statusCode, 156 httpStatusCode: httpHeadersResult.statusCode,
@@ -109,7 +175,9 @@ struct DomainInspectionService {
109 portScanResults: portScanResults.value, 175 portScanResults: portScanResults.value,
110 portScanError: portScanResults.message, 176 portScanError: portScanResults.message,
111 changeSummary: nil, 177 changeSummary: nil,
112 isLive: false 178 resultSource: aggregateSource(sectionSources),
179 cachedSections: Array(cachedSections).sorted { $0.rawValue < $1.rawValue },
180 statusMessage: nil
113 ) 181 )
114 } 182 }
115 183
@@ -122,6 +190,115 @@ struct DomainInspectionService {
122 .lowercased() ?? domain.lowercased() 190 .lowercased() ?? domain.lowercased()
123 } 191 }
124 192
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
125 private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) { 302 private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
126 switch result { 303 switch result {
127 case let .success(value): 304 case let .success(value):
DomainReportBuilder.swift +2
@@ -3,6 +3,7 @@ import Foundation
3struct DomainReport: Codable { 3struct DomainReport: Codable {
4 let domain: String 4 let domain: String
5 let timestamp: Date 5 let timestamp: Date
6 let resultSource: LookupResultSource
6 let availability: DomainAvailabilityStatus 7 let availability: DomainAvailabilityStatus
7 let ownership: DomainOwnership? 8 let ownership: DomainOwnership?
8 let dns: DNSResultSummary 9 let dns: DNSResultSummary
@@ -70,6 +71,7 @@ struct DomainReportBuilder {
70 return DomainReport( 71 return DomainReport(
71 domain: snapshot.domain, 72 domain: snapshot.domain,
72 timestamp: snapshot.timestamp, 73 timestamp: snapshot.timestamp,
74 resultSource: snapshot.resultSource,
73 availability: snapshot.availabilityResult?.status ?? .unknown, 75 availability: snapshot.availabilityResult?.status ?? .unknown,
74 ownership: snapshot.ownership, 76 ownership: snapshot.ownership,
75 dns: DNSResultSummary( 77 dns: DNSResultSummary(
DomainReportExporter.swift +3
@@ -36,6 +36,7 @@ enum DomainReportExporter {
36 "DomainDig Report", 36 "DomainDig Report",
37 "Domain: \(report.domain)", 37 "Domain: \(report.domain)",
38 "Timestamp: \(textDateFormatter.string(from: report.timestamp))", 38 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "Source: \(report.resultSource.label)",
39 "Availability: \(availabilityLabel(report.availability))" 40 "Availability: \(availabilityLabel(report.availability))"
40 ] 41 ]
41 42
@@ -212,6 +213,7 @@ enum DomainReportExporter {
212 let headers = [ 213 let headers = [
213 "domain", 214 "domain",
214 "timestamp", 215 "timestamp",
216 "result_source",
215 "availability", 217 "availability",
216 "registrar", 218 "registrar",
217 "ownership_expires", 219 "ownership_expires",
@@ -247,6 +249,7 @@ enum DomainReportExporter {
247 return [ 249 return [
248 report.domain, 250 report.domain,
249 csvDateFormatter.string(from: report.timestamp), 251 csvDateFormatter.string(from: report.timestamp),
252 report.resultSource.rawValue,
250 availabilityLabel(report.availability), 253 availabilityLabel(report.availability),
251 report.ownership?.registrar ?? "", 254 report.ownership?.registrar ?? "",
252 expirationDate, 255 expirationDate,
LookupSnapshot.swift +10 −2
@@ -39,7 +39,13 @@ struct LookupSnapshot {
39 let portScanResults: [PortScanResult] 39 let portScanResults: [PortScanResult]
40 let portScanError: String? 40 let portScanError: String?
41 let changeSummary: DomainChangeSummary? 41 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 }
43} 49}
44 50
45extension HistoryEntry { 51extension HistoryEntry {
@@ -83,7 +89,9 @@ extension HistoryEntry {
83 portScanResults: portScanResults, 89 portScanResults: portScanResults,
84 portScanError: portScanError, 90 portScanError: portScanError,
85 changeSummary: changeSummary, 91 changeSummary: changeSummary,
86 isLive: false 92 resultSource: .snapshot,
93 cachedSections: [],
94 statusMessage: nil
87 ) 95 )
88 } 96 }
89} 97}