Commit e73d58ee5d

e73d58ee5dd43eaeaaa717f7781cc22256df30f0

parent: 22ecca12b4

Verified · cmc

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

feat(v2.4.0): add JSON output, CLI foundation, and shared report layer

* introduce DomainReport as canonical output model
* add JSON export for single and batch results
* create DomainReportBuilder for reusable report construction
* add CLI target using shared inspection pipeline
* refactor services for UI-independent usage
* ensure consistency across TXT, CSV, and JSON outputs

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +119 −4
@@ -6,25 +6,59 @@
66 objectVersion = 77;
77 objects = {
88
9/* Begin PBXBuildFile section */
10 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */; };
11 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.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 */; };
14 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
15/* End PBXBuildFile section */
16
917/* Begin PBXFileReference section */
1018 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>"; };
21 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>"; };
23 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>"; };
1125/* End PBXFileReference section */
1226
1327/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
1428 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = {
1529 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
1630 membershipExceptions = (
31 DomainDigCLI.swift,
1732 Info.plist,
1833 );
1934 target = 8B7800682F6090E300933221 /* DomainDig */;
2035 };
2136/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
37 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = {
38 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
39 membershipExceptions = (
40 Assets.xcassets,
41 BatchResultsView.swift,
42 BatchSweepSummaryView.swift,
43 ContentView.swift,
44 DomainDigApp.swift,
45 DomainViewModel.swift,
46 ExportPresenter.swift,
47 HistoryView.swift,
48 Info.plist,
49 LocalNotificationService.swift,
50 SavedDomainsView.swift,
51 WatchlistView.swift,
52 );
53 target = 8BF124FB2F70000100933221 /* DomainDigCLI */;
54 };
2255
2356/* Begin PBXFileSystemSynchronizedRootGroup section */
2457 8B78006B2F6090E300933221 /* DomainDig */ = {
2558 isa = PBXFileSystemSynchronizedRootGroup;
2659 exceptions = (
2760 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
61 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */,
2862 );
2963 path = DomainDig;
3064 sourceTree = "<group>";
@@ -39,6 +73,13 @@
3973 );
4074 runOnlyForDeploymentPostprocessing = 0;
4175 };
76 8BF124FD2F70000100933221 /* Frameworks */ = {
77 isa = PBXFrameworksBuildPhase;
78 buildActionMask = 2147483647;
79 files = (
80 );
81 runOnlyForDeploymentPostprocessing = 0;
82 };
4283/* End PBXFrameworksBuildPhase section */
4384
4485/* Begin PBXGroup section */
@@ -47,6 +88,11 @@
4788 children = (
4889 8B78006B2F6090E300933221 /* DomainDig */,
4990 8B78006A2F6090E300933221 /* Products */,
91 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
92 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
93 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
94 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
95 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
5096 );
5197 sourceTree = "<group>";
5298 };
@@ -54,6 +100,7 @@
54100 isa = PBXGroup;
55101 children = (
56102 8B7800692F6090E300933221 /* DomainDig.app */,
103 8BF124F92F70000100933221 /* domaindig */,
57104 );
58105 name = Products;
59106 sourceTree = "<group>";
@@ -83,6 +130,27 @@
83130 productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
84131 productType = "com.apple.product-type.application";
85132 };
133 8BF124FB2F70000100933221 /* DomainDigCLI */ = {
134 isa = PBXNativeTarget;
135 buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */;
136 buildPhases = (
137 8BF124FC2F70000100933221 /* Sources */,
138 8BF124FD2F70000100933221 /* Frameworks */,
139 );
140 buildRules = (
141 );
142 dependencies = (
143 );
144 fileSystemSynchronizedGroups = (
145 8B78006B2F6090E300933221 /* DomainDig */,
146 );
147 name = DomainDigCLI;
148 packageProductDependencies = (
149 );
150 productName = domaindig;
151 productReference = 8BF124F92F70000100933221 /* domaindig */;
152 productType = "com.apple.product-type.tool";
153 };
86154/* End PBXNativeTarget section */
87155
88156/* Begin PBXProject section */
@@ -113,6 +181,7 @@
113181 projectRoot = "";
114182 targets = (
115183 8B7800682F6090E300933221 /* DomainDig */,
184 8BF124FB2F70000100933221 /* DomainDigCLI */,
116185 );
117186 };
118187/* End PBXProject section */
@@ -132,6 +201,11 @@
132201 isa = PBXSourcesBuildPhase;
133202 buildActionMask = 2147483647;
134203 files = (
204 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
205 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
206 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
207 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
208 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
135209 );
136210 runOnlyForDeploymentPostprocessing = 0;
137211 };
@@ -267,7 +341,7 @@
267341 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268342 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269343 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 16;
344 CURRENT_PROJECT_VERSION = 17;
271345 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272346 ENABLE_PREVIEWS = YES;
273347 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +358,7 @@
284358 "$(inherited)",
285359 "@executable_path/Frameworks",
286360 );
287 MARKETING_VERSION = 2.3.0;
361 MARKETING_VERSION = 2.4.0;
288362 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289363 PRODUCT_NAME = "$(TARGET_NAME)";
290364 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +377,7 @@
303377 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304378 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305379 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 16;
380 CURRENT_PROJECT_VERSION = 17;
307381 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308382 ENABLE_PREVIEWS = YES;
309383 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +394,7 @@
320394 "$(inherited)",
321395 "@executable_path/Frameworks",
322396 );
323 MARKETING_VERSION = 2.3.0;
397 MARKETING_VERSION = 2.4.0;
324398 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325399 PRODUCT_NAME = "$(TARGET_NAME)";
326400 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -333,6 +407,47 @@
333407 };
334408 name = Release;
335409 };
410 8BF125002F70000100933221 /* Debug */ = {
411 isa = XCBuildConfiguration;
412 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;
422 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 };
429 name = Debug;
430 };
431 8BF125012F70000100933221 /* Release */ = {
432 isa = XCBuildConfiguration;
433 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;
442 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 };
449 name = Release;
450 };
336451/* End XCBuildConfiguration section */
337452
338453/* Begin XCConfigurationList section */
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +5
@@ -5,6 +5,11 @@
55 <key>SchemeUserState</key>
66 <dict>
77 <key>DomainDig.xcscheme_^#shared#^_</key>
8 <dict>
9 <key>orderHint</key>
10 <integer>1</integer>
11 </dict>
12 <key>DomainDigCLI.xcscheme_^#shared#^_</key>
813 <dict>
914 <key>orderHint</key>
1015 <integer>0</integer>
DomainDig/ContentView.swift +40 −16
@@ -304,10 +304,13 @@ struct ContentView: View {
304304 }
305305 Menu {
306306 Button("Export TXT") {
307 shareSingleResults(asCSV: false)
307 shareSingleResults(format: .text)
308308 }
309309 Button("Export CSV") {
310 shareSingleResults(asCSV: true)
310 shareSingleResults(format: .csv)
311 }
312 Button("Export JSON") {
313 shareSingleResults(format: .json)
311314 }
312315 } label: {
313316 Image(systemName: "square.and.arrow.up")
@@ -332,10 +335,13 @@ struct ContentView: View {
332335 if !viewModel.currentBatchResultEntries.isEmpty {
333336 Menu {
334337 Button("Export Batch TXT") {
335 shareBatchResults(asCSV: false)
338 shareBatchResults(format: .text)
336339 }
337340 Button("Export Batch CSV") {
338 shareBatchResults(asCSV: true)
341 shareBatchResults(format: .csv)
342 }
343 Button("Export Batch JSON") {
344 shareBatchResults(format: .json)
339345 }
340346 } label: {
341347 Label("Export", systemImage: "square.and.arrow.up")
@@ -412,33 +418,51 @@ struct ContentView: View {
412418 return ports
413419 }
414420
415 private func shareSingleResults(asCSV: Bool) {
416 let (filename, contents) = exportPayload(
421 private func shareSingleResults(format: DomainExportFormat) {
422 let (filename, data) = exportPayload(
417423 prefix: "domaindig_single",
424 format: format,
418425 text: viewModel.exportText(),
419426 csv: viewModel.exportCSV(),
420 asCSV: asCSV
427 json: viewModel.exportJSONData()
421428 )
422 ExportPresenter.share(filename: filename, contents: contents)
429 ExportPresenter.share(filename: filename, data: data)
423430 }
424431
425 private func shareBatchResults(asCSV: Bool) {
426 let (filename, contents) = exportPayload(
432 private func shareBatchResults(format: DomainExportFormat) {
433 let (filename, data) = exportPayload(
427434 prefix: "domaindig_batch",
435 format: format,
428436 text: viewModel.exportBatchText(),
429437 csv: viewModel.exportBatchCSV(),
430 asCSV: asCSV
438 json: viewModel.exportBatchJSONData()
431439 )
432 ExportPresenter.share(filename: filename, contents: contents)
440 ExportPresenter.share(filename: filename, data: data)
433441 }
434442
435 private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) {
443 private func exportPayload(
444 prefix: String,
445 format: DomainExportFormat,
446 text: String,
447 csv: String,
448 json: Data?
449 ) -> (String, Data) {
436450 let formatter = DateFormatter()
437451 formatter.dateFormat = "yyyyMMdd_HHmmss"
438452 let timestamp = formatter.string(from: Date())
439 let fileExtension = asCSV ? "csv" : "txt"
440 let filename = "\(timestamp)_\(prefix).\(fileExtension)"
441 return (filename, asCSV ? csv : text)
453 let filename = "\(timestamp)_\(prefix).\(format.fileExtension)"
454 let data: Data
455
456 switch format {
457 case .text:
458 data = Data(text.utf8)
459 case .csv:
460 data = Data(csv.utf8)
461 case .json:
462 data = json ?? Data("[]".utf8)
463 }
464
465 return (filename, data)
442466 }
443467}
444468
DomainDig/DomainViewModel.swift +69 −361
@@ -91,94 +91,6 @@ struct DomainSuggestionViewData: Identifiable {
9191 let tone: ResultTone
9292}
9393
94struct LookupSnapshot {
95 let historyEntryID: UUID?
96 let domain: String
97 let timestamp: Date
98 let trackedDomainID: UUID?
99 let resolverDisplayName: String
100 let resolverURLString: String
101 let totalLookupDurationMs: Int?
102 let dnsSections: [DNSSection]
103 let dnsError: String?
104 let availabilityResult: DomainAvailabilityResult?
105 let suggestions: [DomainSuggestionResult]
106 let sslInfo: SSLCertificateInfo?
107 let sslError: String?
108 let hstsPreloaded: Bool?
109 let httpHeaders: [HTTPHeader]
110 let httpSecurityGrade: String?
111 let httpStatusCode: Int?
112 let httpResponseTimeMs: Int?
113 let httpProtocol: String?
114 let http3Advertised: Bool
115 let httpHeadersError: String?
116 let reachabilityResults: [PortReachability]
117 let reachabilityError: String?
118 let ipGeolocation: IPGeolocation?
119 let ipGeolocationError: String?
120 let emailSecurity: EmailSecurityResult?
121 let emailSecurityError: String?
122 let ownership: DomainOwnership?
123 let ownershipError: String?
124 let ptrRecord: String?
125 let ptrError: String?
126 let redirectChain: [RedirectHop]
127 let redirectChainError: String?
128 let subdomains: [DiscoveredSubdomain]
129 let subdomainsError: String?
130 let portScanResults: [PortScanResult]
131 let portScanError: String?
132 let changeSummary: DomainChangeSummary?
133 let isLive: Bool
134}
135
136extension HistoryEntry {
137 var snapshot: LookupSnapshot {
138 LookupSnapshot(
139 historyEntryID: id,
140 domain: domain,
141 timestamp: timestamp,
142 trackedDomainID: trackedDomainID,
143 resolverDisplayName: resolverDisplayName,
144 resolverURLString: resolverURLString,
145 totalLookupDurationMs: totalLookupDurationMs,
146 dnsSections: dnsSections,
147 dnsError: nil,
148 availabilityResult: availabilityResult,
149 suggestions: suggestions,
150 sslInfo: sslInfo,
151 sslError: sslError,
152 hstsPreloaded: hstsPreloaded,
153 httpHeaders: httpHeaders,
154 httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
155 httpStatusCode: nil,
156 httpResponseTimeMs: nil,
157 httpProtocol: nil,
158 http3Advertised: false,
159 httpHeadersError: httpHeadersError,
160 reachabilityResults: reachabilityResults,
161 reachabilityError: reachabilityError,
162 ipGeolocation: ipGeolocation,
163 ipGeolocationError: ipGeolocationError,
164 emailSecurity: emailSecurity,
165 emailSecurityError: emailSecurityError,
166 ownership: ownership,
167 ownershipError: ownershipError,
168 ptrRecord: ptrRecord,
169 ptrError: ptrError,
170 redirectChain: redirectChain,
171 redirectChainError: redirectChainError,
172 subdomains: subdomains,
173 subdomainsError: subdomainsError,
174 portScanResults: portScanResults,
175 portScanError: portScanError,
176 changeSummary: changeSummary,
177 isLive: false
178 )
179 }
180}
181
18294private struct BatchLookupPayload {
18395 let snapshot: LookupSnapshot
18496}
@@ -271,6 +183,7 @@ final class DomainViewModel {
271183 private var lookupStartedAt: Date?
272184 private var activeBatchDomains: [String] = []
273185 private var lastBatchStartedAt: Date?
186 private let reportBuilder = DomainReportBuilder()
274187
275188 private static let recentSearchesKey = "recentSearches"
276189 private static let maxRecent = 20
@@ -484,6 +397,18 @@ final class DomainViewModel {
484397 )
485398 }
486399
400 var currentReport: DomainReport? {
401 guard !searchedDomain.isEmpty else { return nil }
402 return reportBuilder.build(
403 from: currentSnapshot,
404 previousSnapshot: previousSnapshot(
405 for: searchedDomain,
406 trackedDomainID: currentTrackedDomain?.id,
407 replacingLatest: false
408 )
409 )
410 }
411
487412 var summaryFields: [SummaryFieldViewData] {
488413 Self.summaryFields(from: currentSnapshot)
489414 }
@@ -768,64 +693,55 @@ final class DomainViewModel {
768693 }
769694
770695 func exportText() -> String {
771 Self.formatExportText(
772 from: currentSnapshot,
773 trackedDomain: currentTrackedDomain,
774 changeSummary: currentChangeSummary,
775 diffSections: currentDiffSections
776 )
696 guard let currentReport else { return "No results available." }
697 return DomainReportExporter.text(for: currentReport)
777698 }
778699
779700 func exportCSV() -> String {
780 Self.formatCSV(from: [currentSnapshot])
701 guard let currentReport else { return DomainReportExporter.csv(for: []) }
702 return DomainReportExporter.csv(for: [currentReport])
703 }
704
705 func exportJSONData() -> Data? {
706 guard let currentReport else { return nil }
707 return try? DomainReportExporter.data(for: currentReport, format: .json)
781708 }
782709
783710 func exportBatchText() -> String {
784 Self.formatBatchExportText(
785 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export",
786 entries: currentBatchResultEntries.map { entry in
787 (
788 snapshot: entry.snapshot,
789 trackedDomain: trackedDomains.first(where: { tracked in
790 tracked.id == entry.trackedDomainID ||
791 tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame
792 }),
793 changeSummary: entry.changeSummary,
794 diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
795 )
796 }
711 DomainReportExporter.batchText(
712 for: currentBatchReports(),
713 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
797714 )
798715 }
799716
800717 func exportBatchCSV() -> String {
801 Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot))
718 DomainReportExporter.csv(for: currentBatchReports())
719 }
720
721 func exportBatchJSONData() -> Data? {
722 try? DomainReportExporter.data(
723 for: currentBatchReports(),
724 format: .json,
725 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
726 )
802727 }
803728
804729 func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
805 Self.formatCSV(from: exportSnapshots(for: domains))
730 DomainReportExporter.csv(for: reports(for: domains))
806731 }
807732
808733 func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
809 let latestEntries = latestSnapshots(for: domains)
810 return Self.formatBatchExportText(
811 title: "Tracked Domains Export",
812 entries: domains.map { trackedDomain in
813 if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
814 return (
815 snapshot: entry.snapshot,
816 trackedDomain: trackedDomain,
817 changeSummary: entry.changeSummary,
818 diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
819 )
820 }
734 DomainReportExporter.batchText(
735 for: reports(for: domains),
736 title: "Tracked Domains Export"
737 )
738 }
821739
822 return (
823 snapshot: placeholderSnapshot(for: trackedDomain),
824 trackedDomain: trackedDomain,
825 changeSummary: trackedDomain.lastChangeSummary,
826 diffSections: []
827 )
828 }
740 func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? {
741 try? DomainReportExporter.data(
742 for: reports(for: domains),
743 format: .json,
744 title: "Tracked Domains Export"
829745 )
830746 }
831747
@@ -1130,239 +1046,8 @@ final class DomainViewModel {
11301046
11311047 private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
11321048 guard !Task.isCancelled else { return nil }
1133
1134 let startedAt = Date()
1135 let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
1136 let resolverURLString = DNSLookupService.currentResolverURLString()
1137
1138 async let dnsResult = DNSLookupService.lookupAll(domain: domain)
1139 async let availabilityResult = DomainAvailabilityService.check(domain: domain)
1140 async let sslResult = SSLCheckService.check(domain: domain)
1141 async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
1142 async let httpResult = HTTPHeadersService.fetch(domain: domain)
1143 async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
1144 async let ownershipResult = DomainOwnershipService.lookup(domain: domain)
1145 async let redirectResult = RedirectChainService.trace(domain: domain)
1146 async let subdomainResult = SubdomainDiscoveryService.discover(for: domain)
1147 async let portScanResult = PortScanService.scanAll(domain: domain)
1148
1149 let resolvedDNS = await dnsResult
1150 let availability = await availabilityResult
1151 let resolvedSSL = await sslResult
1152 let hsts = await hstsResult
1153 let http = await httpResult
1154 let reachability = await reachabilityResult
1155 let resolvedOwnership = await ownershipResult
1156 let redirects = await redirectResult
1157 let resolvedSubdomains = await subdomainResult
1158 let ports = await portScanResult
1159
1049 let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain)
11601050 guard !Task.isCancelled else { return nil }
1161
1162 let dnsSections: [DNSSection]
1163 let dnsError: String?
1164 switch resolvedDNS {
1165 case let .success(sections):
1166 dnsSections = sections
1167 dnsError = nil
1168 case let .empty(message), let .error(message):
1169 dnsSections = []
1170 dnsError = message
1171 }
1172
1173 let sslInfo: SSLCertificateInfo?
1174 let sslError: String?
1175 switch resolvedSSL {
1176 case let .success(info):
1177 sslInfo = info
1178 sslError = nil
1179 case let .empty(message), let .error(message):
1180 sslInfo = nil
1181 sslError = message
1182 }
1183
1184 let httpHeaders: [HTTPHeader]
1185 let httpSecurityGrade: String?
1186 let httpStatusCode: Int?
1187 let httpResponseTimeMs: Int?
1188 let httpProtocol: String?
1189 let http3Advertised: Bool
1190 let httpHeadersError: String?
1191 switch http {
1192 case let .success(result):
1193 httpHeaders = result.headers
1194 httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
1195 httpStatusCode = result.statusCode
1196 httpResponseTimeMs = result.responseTimeMs
1197 httpProtocol = result.httpProtocol
1198 http3Advertised = result.http3Advertised
1199 httpHeadersError = nil
1200 case let .empty(message), let .error(message):
1201 httpHeaders = []
1202 httpSecurityGrade = nil
1203 httpStatusCode = nil
1204 httpResponseTimeMs = nil
1205 httpProtocol = nil
1206 http3Advertised = false
1207 httpHeadersError = message
1208 }
1209
1210 let reachabilityResults: [PortReachability]
1211 let reachabilityError: String?
1212 switch reachability {
1213 case let .success(results):
1214 reachabilityResults = results
1215 reachabilityError = nil
1216 case let .empty(message), let .error(message):
1217 reachabilityResults = []
1218 reachabilityError = message
1219 }
1220
1221 let redirectChain: [RedirectHop]
1222 let redirectChainError: String?
1223 switch redirects {
1224 case let .success(hops):
1225 redirectChain = hops
1226 redirectChainError = nil
1227 case let .empty(message), let .error(message):
1228 redirectChain = []
1229 redirectChainError = message
1230 }
1231
1232 let portScanResults: [PortScanResult]
1233 let portScanError: String?
1234 switch ports {
1235 case let .success(results):
1236 portScanResults = await enrichOpenPortBanners(results, domain: domain)
1237 portScanError = nil
1238 case let .empty(message), let .error(message):
1239 portScanResults = []
1240 portScanError = message
1241 }
1242
1243 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
1244 let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
1245
1246 async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
1247 async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : []
1248
1249 let resolvedEmail = await emailResult
1250 let resolvedSuggestions = await suggestions
1251 let resolvedPTR: ServiceResult<String>?
1252 let resolvedGeo: ServiceResult<IPGeolocation>?
1253 if let primaryIP {
1254 resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
1255 resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP)
1256 } else {
1257 resolvedPTR = nil
1258 resolvedGeo = nil
1259 }
1260
1261 guard !Task.isCancelled else { return nil }
1262
1263 let emailSecurity: EmailSecurityResult?
1264 let emailSecurityError: String?
1265 switch resolvedEmail {
1266 case let .success(result):
1267 emailSecurity = result
1268 emailSecurityError = nil
1269 case let .empty(message), let .error(message):
1270 emailSecurity = nil
1271 emailSecurityError = message
1272 }
1273
1274 let ownership: DomainOwnership?
1275 let ownershipError: String?
1276 switch resolvedOwnership {
1277 case let .success(result):
1278 ownership = result
1279 ownershipError = nil
1280 case let .empty(message), let .error(message):
1281 ownership = nil
1282 ownershipError = message
1283 }
1284
1285 let ptrRecord: String?
1286 let ptrError: String?
1287 switch resolvedPTR {
1288 case let .success(record):
1289 ptrRecord = record
1290 ptrError = nil
1291 case let .empty(message), let .error(message):
1292 ptrRecord = nil
1293 ptrError = message
1294 case .none:
1295 ptrRecord = nil
1296 ptrError = "No A record available"
1297 }
1298
1299 let ipGeolocation: IPGeolocation?
1300 let ipGeolocationError: String?
1301 switch resolvedGeo {
1302 case let .success(result):
1303 ipGeolocation = result
1304 ipGeolocationError = nil
1305 case let .empty(message), let .error(message):
1306 ipGeolocation = nil
1307 ipGeolocationError = message
1308 case .none:
1309 ipGeolocation = nil
1310 ipGeolocationError = "No A record available"
1311 }
1312
1313 let subdomains: [DiscoveredSubdomain]
1314 let subdomainsError: String?
1315 switch resolvedSubdomains {
1316 case let .success(result):
1317 subdomains = result
1318 subdomainsError = nil
1319 case let .empty(message), let .error(message):
1320 subdomains = []
1321 subdomainsError = message
1322 }
1323
1324 let snapshot = LookupSnapshot(
1325 historyEntryID: nil,
1326 domain: domain,
1327 timestamp: Date(),
1328 trackedDomainID: nil,
1329 resolverDisplayName: resolverDisplayName,
1330 resolverURLString: resolverURLString,
1331 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
1332 dnsSections: dnsSections,
1333 dnsError: dnsError,
1334 availabilityResult: availability,
1335 suggestions: resolvedSuggestions,
1336 sslInfo: sslInfo,
1337 sslError: sslError,
1338 hstsPreloaded: hsts,
1339 httpHeaders: httpHeaders,
1340 httpSecurityGrade: httpSecurityGrade,
1341 httpStatusCode: httpStatusCode,
1342 httpResponseTimeMs: httpResponseTimeMs,
1343 httpProtocol: httpProtocol,
1344 http3Advertised: http3Advertised,
1345 httpHeadersError: httpHeadersError,
1346 reachabilityResults: reachabilityResults,
1347 reachabilityError: reachabilityError,
1348 ipGeolocation: ipGeolocation,
1349 ipGeolocationError: ipGeolocationError,
1350 emailSecurity: emailSecurity,
1351 emailSecurityError: emailSecurityError,
1352 ownership: ownership,
1353 ownershipError: ownershipError,
1354 ptrRecord: ptrRecord,
1355 ptrError: ptrError,
1356 redirectChain: redirectChain,
1357 redirectChainError: redirectChainError,
1358 subdomains: subdomains,
1359 subdomainsError: subdomainsError,
1360 portScanResults: portScanResults,
1361 portScanError: portScanError,
1362 changeSummary: nil,
1363 isLive: false
1364 )
1365
13661051 return BatchLookupPayload(snapshot: snapshot)
13671052 }
13681053
@@ -1967,6 +1652,29 @@ final class DomainViewModel {
19671652 }
19681653 }
19691654
1655 private func currentBatchReports() -> [DomainReport] {
1656 currentBatchResultEntries.map(report(for:))
1657 }
1658
1659 private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
1660 let latestEntries = latestSnapshots(for: domains)
1661
1662 return domains.map { trackedDomain in
1663 if let entry = latestEntries.first(where: {
1664 $0.trackedDomainID == trackedDomain.id ||
1665 $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
1666 }) {
1667 return report(for: entry)
1668 }
1669
1670 return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
1671 }
1672 }
1673
1674 private func report(for entry: HistoryEntry) -> DomainReport {
1675 reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry))
1676 }
1677
19701678 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
19711679 LookupSnapshot(
19721680 historyEntryID: trackedDomain.lastSnapshotID,
DomainDig/ExportPresenter.swift +5 −1
@@ -3,10 +3,14 @@ import UIKit
33
44enum ExportPresenter {
55 static func share(filename: String, contents: String) {
6 share(filename: filename, data: Data(contents.utf8))
7 }
8
9 static func share(filename: String, data: Data) {
610 let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
711
812 do {
9 try contents.write(to: url, atomically: true, encoding: .utf8)
13 try data.write(to: url, options: .atomic)
1014 } catch {
1115 return
1216 }
DomainDig/WatchlistView.swift +20 −9
@@ -143,11 +143,15 @@ struct WatchlistView: View {
143143 .disabled(viewModel.batchLookupRunning)
144144
145145 Button("Export TXT") {
146 shareTrackedDomains(asCSV: false)
146 shareTrackedDomains(format: .text)
147147 }
148148
149149 Button("Export CSV") {
150 shareTrackedDomains(asCSV: true)
150 shareTrackedDomains(format: .csv)
151 }
152
153 Button("Export JSON") {
154 shareTrackedDomains(format: .json)
151155 }
152156 } label: {
153157 Image(systemName: "line.3.horizontal.decrease.circle")
@@ -178,16 +182,23 @@ struct WatchlistView: View {
178182 domains.forEach(viewModel.deleteTrackedDomain)
179183 }
180184
181 private func shareTrackedDomains(asCSV: Bool) {
185 private func shareTrackedDomains(format: DomainExportFormat) {
182186 let formatter = DateFormatter()
183187 formatter.dateFormat = "yyyyMMdd_HHmmss"
184188 let timestamp = formatter.string(from: Date())
185 let fileExtension = asCSV ? "csv" : "txt"
186 let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)"
187 let contents = asCSV
188 ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains)
189 : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains)
190 ExportPresenter.share(filename: filename, contents: contents)
189 let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
190 let data: Data
191
192 switch format {
193 case .text:
194 data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8)
195 case .csv:
196 data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8)
197 case .json:
198 data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8)
199 }
200
201 ExportPresenter.share(filename: filename, data: data)
191202 }
192203}
193204
DomainDigCLI.swift added +39
@@ -0,0 +1,39 @@
1import Foundation
2
3@main
4struct DomainDigCLI {
5 static func main() async {
6 let arguments = Array(CommandLine.arguments.dropFirst())
7
8 guard let command = CommandLine.arguments.first else {
9 fputs("usage: domaindig <domain> [--json]\n", stderr)
10 Foundation.exit(1)
11 }
12 _ = command
13
14 let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
15 let domains = arguments.filter { !$0.hasPrefix("-") }
16
17 guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
18 fputs("usage: domaindig <domain> [--json]\n", stderr)
19 Foundation.exit(1)
20 }
21
22 let inspectionService = DomainInspectionService()
23 let report = await inspectionService.inspect(domain: domain)
24
25 do {
26 let data = try DomainReportExporter.data(
27 for: report,
28 format: wantsJSON ? .json : .text
29 )
30 FileHandle.standardOutput.write(data)
31 if data.last != 0x0A {
32 FileHandle.standardOutput.write(Data([0x0A]))
33 }
34 } catch {
35 fputs("domaindig: \(error.localizedDescription)\n", stderr)
36 Foundation.exit(1)
37 }
38 }
39}
DomainInspectionService.swift added +225
@@ -0,0 +1,225 @@
1import Foundation
2
3struct DomainInspectionService {
4 private let reportBuilder = DomainReportBuilder()
5
6 func inspect(domain: String) async -> DomainReport {
7 let snapshot = await inspectSnapshot(domain: domain)
8 return reportBuilder.build(from: snapshot)
9 }
10
11 func inspectSnapshot(domain: String) async -> LookupSnapshot {
12 let normalizedDomain = normalize(domain)
13 let startedAt = Date()
14 let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
15 let resolverURLString = DNSLookupService.currentResolverURLString()
16
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)
47
48 let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
49 let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
50
51 async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords)
52 async let suggestions = availability.status == .registered
53 ? DomainAvailabilityService.suggestions(for: normalizedDomain)
54 : []
55
56 let resolvedEmail = await emailResult
57 let resolvedSuggestions = await suggestions
58
59 let ptrResult: ServiceResult<String>?
60 let geoResult: ServiceResult<IPGeolocation>?
61 if let primaryIP {
62 ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
63 geoResult = await IPGeolocationService.lookup(ip: primaryIP)
64 } else {
65 ptrResult = nil
66 geoResult = nil
67 }
68
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")
72
73 return LookupSnapshot(
74 historyEntryID: nil,
75 domain: availability.domain,
76 timestamp: Date(),
77 trackedDomainID: nil,
78 resolverDisplayName: resolverDisplayName,
79 resolverURLString: resolverURLString,
80 totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
81 dnsSections: dnsSections.value,
82 dnsError: dnsSections.message,
83 availabilityResult: availability,
84 suggestions: resolvedSuggestions,
85 sslInfo: sslInfo.value,
86 sslError: sslInfo.message,
87 hstsPreloaded: hsts,
88 httpHeaders: httpHeadersResult.headers,
89 httpSecurityGrade: httpHeadersResult.securityGrade,
90 httpStatusCode: httpHeadersResult.statusCode,
91 httpResponseTimeMs: httpHeadersResult.responseTimeMs,
92 httpProtocol: httpHeadersResult.httpProtocol,
93 http3Advertised: httpHeadersResult.http3Advertised,
94 httpHeadersError: httpHeadersResult.error,
95 reachabilityResults: reachabilityResultValue.value,
96 reachabilityError: reachabilityResultValue.message,
97 ipGeolocation: geolocation.value,
98 ipGeolocationError: geolocation.message,
99 emailSecurity: emailSecurity.value,
100 emailSecurityError: emailSecurity.message,
101 ownership: ownership.value,
102 ownershipError: ownership.message,
103 ptrRecord: ptrRecord.value,
104 ptrError: ptrRecord.message,
105 redirectChain: redirectChain.value,
106 redirectChainError: redirectChain.message,
107 subdomains: subdomains.value,
108 subdomainsError: subdomains.message,
109 portScanResults: portScanResults.value,
110 portScanError: portScanResults.message,
111 changeSummary: nil,
112 isLive: false
113 )
114 }
115
116 private func normalize(_ domain: String) -> String {
117 domain
118 .trimmingCharacters(in: .whitespacesAndNewlines)
119 .replacingOccurrences(of: "https://", with: "")
120 .replacingOccurrences(of: "http://", with: "")
121 .components(separatedBy: "/").first?
122 .lowercased() ?? domain.lowercased()
123 }
124
125 private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
126 switch result {
127 case let .success(value):
128 return (value, nil)
129 case let .empty(message), let .error(message):
130 return (emptyValue, message)
131 }
132 }
133
134 private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) {
135 switch result {
136 case let .success(value):
137 return (value, nil)
138 case let .empty(message), let .error(message):
139 return (nil, message)
140 }
141 }
142
143 private func mapOptionalServiceResult<Value>(
144 _ result: ServiceResult<Value>?,
145 missingMessage: String
146 ) -> (value: Value?, message: String?) {
147 guard let result else {
148 return (nil, missingMessage)
149 }
150
151 switch result {
152 case let .success(value):
153 return (value, nil)
154 case let .empty(message), let .error(message):
155 return (nil, message)
156 }
157 }
158
159 private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) {
160 switch result {
161 case let .success(results):
162 return (await enrichOpenPortBanners(in: results, domain: domain), nil)
163 case let .empty(message), let .error(message):
164 return ([], message)
165 }
166 }
167
168 private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> (
169 headers: [HTTPHeader],
170 securityGrade: String?,
171 statusCode: Int?,
172 responseTimeMs: Int?,
173 httpProtocol: String?,
174 http3Advertised: Bool,
175 error: String?
176 ) {
177 switch result {
178 case let .success(value):
179 return (
180 headers: value.headers,
181 securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue,
182 statusCode: value.statusCode,
183 responseTimeMs: value.responseTimeMs,
184 httpProtocol: value.httpProtocol,
185 http3Advertised: value.http3Advertised,
186 error: nil
187 )
188 case let .empty(message), let .error(message):
189 return (
190 headers: [],
191 securityGrade: nil,
192 statusCode: nil,
193 responseTimeMs: nil,
194 httpProtocol: nil,
195 http3Advertised: false,
196 error: message
197 )
198 }
199 }
200
201 private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
202 let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
203 for result in results where result.open {
204 group.addTask {
205 let banner = await PortScanService.grabBanner(host: domain, port: result.port)
206 return (result.port, banner)
207 }
208 }
209
210 var collected: [UInt16: String] = [:]
211 for await (port, banner) in group {
212 if let banner {
213 collected[port] = banner
214 }
215 }
216 return collected
217 }
218
219 return results.map { result in
220 var updated = result
221 updated.banner = banners[result.port]
222 return updated
223 }
224 }
225}
DomainReportBuilder.swift added +190
@@ -0,0 +1,190 @@
1import Foundation
2
3struct DomainReport: Codable {
4 let domain: String
5 let timestamp: Date
6 let availability: DomainAvailabilityStatus
7 let ownership: DomainOwnership?
8 let dns: DNSResultSummary
9 let web: WebResultSummary
10 let email: EmailSecuritySummary
11 let network: NetworkSummary
12 let subdomains: [String]
13 let changeSummary: DomainChangeSummary?
14}
15
16struct DNSResultSummary: Codable {
17 let resolverDisplayName: String
18 let resolverURLString: String
19 let lookupDurationMs: Int?
20 let recordSections: [DNSSection]
21 let primaryIP: String?
22 let ptrRecord: String?
23 let dnssecSigned: Bool?
24 let error: String?
25 let ptrError: String?
26}
27
28struct WebResultSummary: Codable {
29 let tls: SSLCertificateInfo?
30 let tlsStatus: String
31 let certificateWarningLevel: CertificateWarningLevel
32 let hstsPreloaded: Bool?
33 let headers: [HTTPHeader]
34 let headerCount: Int
35 let securityGrade: String?
36 let statusCode: Int?
37 let responseTimeMs: Int?
38 let httpProtocol: String?
39 let http3Advertised: Bool
40 let redirectChain: [RedirectHop]
41 let finalURL: String?
42 let tlsError: String?
43 let headersError: String?
44 let redirectError: String?
45}
46
47struct EmailSecuritySummary: Codable {
48 let records: EmailSecurityResult?
49 let summary: String
50 let error: String?
51}
52
53struct NetworkSummary: Codable {
54 let primaryIP: String?
55 let reachability: [PortReachability]
56 let reachabilitySummary: String
57 let reachabilityError: String?
58 let geolocation: IPGeolocation?
59 let geolocationSummary: String
60 let geolocationError: String?
61 let portScan: [PortScanResult]
62 let openPorts: [UInt16]
63 let portScanError: String?
64}
65
66struct DomainReportBuilder {
67 func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
68 let primaryIP = primaryIPAddress(from: snapshot)
69
70 return DomainReport(
71 domain: snapshot.domain,
72 timestamp: snapshot.timestamp,
73 availability: snapshot.availabilityResult?.status ?? .unknown,
74 ownership: snapshot.ownership,
75 dns: DNSResultSummary(
76 resolverDisplayName: snapshot.resolverDisplayName,
77 resolverURLString: snapshot.resolverURLString,
78 lookupDurationMs: snapshot.totalLookupDurationMs,
79 recordSections: snapshot.dnsSections,
80 primaryIP: primaryIP,
81 ptrRecord: snapshot.ptrRecord,
82 dnssecSigned: dnssecSigned(from: snapshot),
83 error: snapshot.dnsError,
84 ptrError: snapshot.ptrError
85 ),
86 web: WebResultSummary(
87 tls: snapshot.sslInfo,
88 tlsStatus: tlsStatus(from: snapshot),
89 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
90 hstsPreloaded: snapshot.hstsPreloaded,
91 headers: snapshot.httpHeaders,
92 headerCount: snapshot.httpHeaders.count,
93 securityGrade: snapshot.httpSecurityGrade,
94 statusCode: snapshot.httpStatusCode,
95 responseTimeMs: snapshot.httpResponseTimeMs,
96 httpProtocol: snapshot.httpProtocol,
97 http3Advertised: snapshot.http3Advertised,
98 redirectChain: snapshot.redirectChain,
99 finalURL: snapshot.redirectChain.last?.url,
100 tlsError: snapshot.sslError,
101 headersError: snapshot.httpHeadersError,
102 redirectError: snapshot.redirectChainError
103 ),
104 email: EmailSecuritySummary(
105 records: snapshot.emailSecurity,
106 summary: emailSummary(from: snapshot),
107 error: snapshot.emailSecurityError
108 ),
109 network: NetworkSummary(
110 primaryIP: primaryIP,
111 reachability: snapshot.reachabilityResults,
112 reachabilitySummary: reachabilitySummary(from: snapshot),
113 reachabilityError: snapshot.reachabilityError,
114 geolocation: snapshot.ipGeolocation,
115 geolocationSummary: geolocationSummary(from: snapshot),
116 geolocationError: snapshot.ipGeolocationError,
117 portScan: snapshot.portScanResults,
118 openPorts: snapshot.portScanResults.filter(\.open).map(\.port),
119 portScanError: snapshot.portScanError
120 ),
121 subdomains: snapshot.subdomains.map(\.hostname),
122 changeSummary: snapshot.changeSummary ?? previousSnapshot.map {
123 DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
124 }
125 )
126 }
127
128 func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
129 build(from: entry.snapshot, previousSnapshot: previousSnapshot)
130 }
131
132 private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
133 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
134 }
135
136 private func dnssecSigned(from snapshot: LookupSnapshot) -> Bool? {
137 snapshot.dnsSections.compactMap(\.dnssecSigned).first
138 }
139
140 private func tlsStatus(from snapshot: LookupSnapshot) -> String {
141 if snapshot.sslInfo != nil {
142 return "valid"
143 }
144 if let sslError = snapshot.sslError {
145 return sslError.localizedCaseInsensitiveContains("certificate") ? "invalid" : "failed"
146 }
147 return "unavailable"
148 }
149
150 private func emailSummary(from snapshot: LookupSnapshot) -> String {
151 guard let emailSecurity = snapshot.emailSecurity else {
152 return snapshot.emailSecurityError ?? "Unavailable"
153 }
154
155 return [
156 "SPF \(emailSecurity.spf.found ? "Yes" : "No")",
157 "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")",
158 "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")",
159 "BIMI \(emailSecurity.bimi.found ? "Yes" : "No")",
160 "MTA-STS \(emailSecurity.mtaSts?.txtFound == true ? "Yes" : "No")"
161 ].joined(separator: " / ")
162 }
163
164 private func reachabilitySummary(from snapshot: LookupSnapshot) -> String {
165 guard !snapshot.reachabilityResults.isEmpty else {
166 return snapshot.reachabilityError ?? "Unavailable"
167 }
168
169 return snapshot.reachabilityResults
170 .sorted { $0.port < $1.port }
171 .map { "\($0.port):\($0.reachable ? "open" : "closed")" }
172 .joined(separator: ", ")
173 }
174
175 private func geolocationSummary(from snapshot: LookupSnapshot) -> String {
176 guard let geolocation = snapshot.ipGeolocation else {
177 return snapshot.ipGeolocationError ?? "Unavailable"
178 }
179
180 let parts = [geolocation.city, geolocation.region, geolocation.country_name]
181 .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
182 .filter { !$0.isEmpty }
183
184 if !parts.isEmpty {
185 return parts.joined(separator: ", ")
186 }
187
188 return geolocation.ip
189 }
190}
DomainReportExporter.swift added +367
@@ -0,0 +1,367 @@
1import Foundation
2
3enum DomainExportFormat: String {
4 case text = "txt"
5 case csv = "csv"
6 case json = "json"
7
8 var fileExtension: String { rawValue }
9}
10
11enum DomainReportExporter {
12 static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
13 switch format {
14 case .text:
15 return Data(text(for: report).utf8)
16 case .csv:
17 return Data(csv(for: [report]).utf8)
18 case .json:
19 return try jsonEncoder.encode(report)
20 }
21 }
22
23 static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
24 switch format {
25 case .text:
26 return Data(batchText(for: reports, title: title).utf8)
27 case .csv:
28 return Data(csv(for: reports).utf8)
29 case .json:
30 return try jsonEncoder.encode(reports)
31 }
32 }
33
34 static func text(for report: DomainReport) -> String {
35 var lines = [
36 "DomainDig Report",
37 "Domain: \(report.domain)",
38 "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
39 "Availability: \(availabilityLabel(report.availability))"
40 ]
41
42 appendSection("Summary", to: &lines) {
43 [
44 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
45 "TLS Status: \(report.web.tlsStatus)",
46 "HTTP: \(httpSummary(for: report))",
47 "Email: \(report.email.summary)",
48 "Subdomains: \(report.subdomains.count)"
49 ]
50 }
51
52 appendSection("Ownership", to: &lines) {
53 var ownershipLines = [
54 "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
55 "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
56 "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
57 "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
58 "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
59 "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
60 ]
61 if let error = report.dns.error, report.ownership == nil {
62 ownershipLines.append("Error: \(error)")
63 } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
64 _ = error
65 }
66 return ownershipLines
67 }
68
69 appendSection("DNS", to: &lines) {
70 var dnsLines = [
71 "Resolver: \(report.dns.resolverDisplayName)",
72 "Resolver URL: \(report.dns.resolverURLString)",
73 "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
74 "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
75 "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
76 "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))"
77 ]
78 if let error = report.dns.error {
79 dnsLines.append("Error: \(error)")
80 }
81 if report.dns.recordSections.isEmpty {
82 dnsLines.append("Records: None")
83 } else {
84 dnsLines.append("Records:")
85 for section in report.dns.recordSections {
86 let values = (section.records + section.wildcardRecords).map(\.value)
87 let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
88 dnsLines.append(" \(section.recordType.rawValue): \(renderedValues)")
89 }
90 }
91 return dnsLines
92 }
93
94 appendSection("Web", to: &lines) {
95 var webLines = [
96 "TLS Status: \(report.web.tlsStatus)",
97 "Certificate Warning: \(report.web.certificateWarningLevel.title)",
98 "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
99 "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
100 "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
101 "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
102 "Final URL: \(report.web.finalURL ?? "Unavailable")",
103 "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
104 "Header Count: \(report.web.headerCount)"
105 ]
106 if let tlsError = report.web.tlsError {
107 webLines.append("TLS Error: \(tlsError)")
108 }
109 if let headersError = report.web.headersError {
110 webLines.append("Headers Error: \(headersError)")
111 }
112 if let redirectError = report.web.redirectError {
113 webLines.append("Redirect Error: \(redirectError)")
114 }
115 if !report.web.headers.isEmpty {
116 webLines.append("Headers:")
117 for header in report.web.headers {
118 webLines.append(" \(header.name): \(header.value)")
119 }
120 }
121 if !report.web.redirectChain.isEmpty {
122 webLines.append("Redirect Chain:")
123 for hop in report.web.redirectChain {
124 webLines.append(" \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
125 }
126 }
127 return webLines
128 }
129
130 appendSection("Email", to: &lines) {
131 var emailLines = [report.email.summary]
132 if let records = report.email.records {
133 emailLines.append("SPF: \(recordLabel(records.spf))")
134 emailLines.append("DMARC: \(recordLabel(records.dmarc))")
135 emailLines.append("DKIM: \(recordLabel(records.dkim))")
136 emailLines.append("BIMI: \(recordLabel(records.bimi))")
137 emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
138 }
139 if let error = report.email.error {
140 emailLines.append("Error: \(error)")
141 }
142 return emailLines
143 }
144
145 appendSection("Network", to: &lines) {
146 var networkLines = [
147 "Reachability: \(report.network.reachabilitySummary)",
148 "Geolocation: \(report.network.geolocationSummary)",
149 "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
150 ]
151 if let error = report.network.reachabilityError {
152 networkLines.append("Reachability Error: \(error)")
153 }
154 if let error = report.network.geolocationError {
155 networkLines.append("Geolocation Error: \(error)")
156 }
157 if let error = report.network.portScanError {
158 networkLines.append("Port Scan Error: \(error)")
159 }
160 if !report.network.portScan.isEmpty {
161 networkLines.append("Port Scan:")
162 for result in report.network.portScan {
163 networkLines.append(
164 " \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
165 )
166 }
167 }
168 return networkLines
169 }
170
171 appendSection("Subdomains", to: &lines) {
172 if report.subdomains.isEmpty {
173 return ["None"]
174 }
175 return report.subdomains.map { "- \($0)" }
176 }
177
178 appendSection("Changes", to: &lines) {
179 guard let changeSummary = report.changeSummary else {
180 return ["No comparison available"]
181 }
182
183 return [
184 "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
185 "Severity: \(changeSummary.severity.title)",
186 "Summary: \(changeSummary.message)",
187 "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
188 ]
189 }
190
191 return lines.joined(separator: "\n")
192 }
193
194 static func batchText(for reports: [DomainReport], title: String) -> String {
195 guard !reports.isEmpty else {
196 return "\(title)\nNo results available."
197 }
198
199 var lines = [title, String(repeating: "=", count: title.count), ""]
200 for (index, report) in reports.enumerated() {
201 if index > 0 {
202 lines.append("")
203 lines.append(String(repeating: "=", count: 48))
204 lines.append("")
205 }
206 lines.append(text(for: report))
207 }
208 return lines.joined(separator: "\n")
209 }
210
211 static func csv(for reports: [DomainReport]) -> String {
212 let headers = [
213 "domain",
214 "timestamp",
215 "availability",
216 "registrar",
217 "ownership_expires",
218 "nameservers",
219 "primary_ip",
220 "ptr_record",
221 "dnssec_signed",
222 "tls_status",
223 "certificate_warning_level",
224 "hsts_preloaded",
225 "http_status",
226 "http_security_grade",
227 "final_url",
228 "email_summary",
229 "subdomain_count",
230 "subdomains",
231 "open_ports",
232 "reachability_summary",
233 "geolocation_summary",
234 "change_summary"
235 ]
236
237 let rows = reports.map { report in
238 let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
239 let nameservers = joined(report.ownership?.nameservers) ?? ""
240 let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
241 let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
242 let httpStatus = report.web.statusCode.map(String.init) ?? ""
243 let subdomainCount = String(report.subdomains.count)
244 let subdomains = report.subdomains.joined(separator: " | ")
245 let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
246
247 return [
248 report.domain,
249 csvDateFormatter.string(from: report.timestamp),
250 availabilityLabel(report.availability),
251 report.ownership?.registrar ?? "",
252 expirationDate,
253 nameservers,
254 report.dns.primaryIP ?? "",
255 report.dns.ptrRecord ?? "",
256 dnssecSigned,
257 report.web.tlsStatus,
258 report.web.certificateWarningLevel.rawValue,
259 hstsPreloaded,
260 httpStatus,
261 report.web.securityGrade ?? "",
262 report.web.finalURL ?? "",
263 report.email.summary,
264 subdomainCount,
265 subdomains,
266 openPorts,
267 report.network.reachabilitySummary,
268 report.network.geolocationSummary,
269 report.changeSummary?.message ?? ""
270 ]
271 }
272
273 return ([headers] + rows)
274 .map { row in row.map(csvEscaped).joined(separator: ",") }
275 .joined(separator: "\n")
276 }
277
278 private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
279 lines.append("")
280 lines.append(title)
281 lines.append(String(repeating: "-", count: title.count))
282 lines.append(contentsOf: body())
283 }
284
285 private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
286 switch status {
287 case .available:
288 return "Available"
289 case .registered:
290 return "Registered"
291 case .unknown:
292 return "Unknown"
293 }
294 }
295
296 private static func joined(_ values: [String]?) -> String? {
297 guard let values, !values.isEmpty else { return nil }
298 return values.joined(separator: " | ")
299 }
300
301 private static func dnssecLabel(_ value: Bool?) -> String {
302 switch value {
303 case true:
304 return "Signed"
305 case false:
306 return "Unsigned"
307 case nil:
308 return "Unavailable"
309 }
310 }
311
312 private static func ownershipDateLabel(_ date: Date?) -> String {
313 guard let date else { return "Unavailable" }
314 return textDateFormatter.string(from: date)
315 }
316
317 private static func durationLabel(_ durationMs: Int?) -> String {
318 durationMs.map { "\($0) ms" } ?? "Unavailable"
319 }
320
321 private static func httpSummary(for report: DomainReport) -> String {
322 let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
323 return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
324 }
325
326 private static func booleanLabel(_ value: Bool?) -> String {
327 guard let value else { return "Unavailable" }
328 return value ? "Yes" : "No"
329 }
330
331 private static func recordLabel(_ record: EmailSecurityRecord) -> String {
332 if record.found {
333 return record.value ?? "Present"
334 }
335 return "Unavailable"
336 }
337
338 private static let textDateFormatter: DateFormatter = {
339 let formatter = DateFormatter()
340 formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
341 return formatter
342 }()
343
344 private static let csvDateFormatter: ISO8601DateFormatter = {
345 let formatter = ISO8601DateFormatter()
346 formatter.formatOptions = [.withInternetDateTime]
347 return formatter
348 }()
349
350 private static let jsonEncoder: JSONEncoder = {
351 let encoder = JSONEncoder()
352 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
353 encoder.dateEncodingStrategy = .iso8601
354 return encoder
355 }()
356
357 nonisolated private static func csvEscaped(_ value: String) -> String {
358 let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
359 return "\"\(escaped)\""
360 }
361}
362
363private extension String {
364 var nilIfEmpty: String? {
365 isEmpty ? nil : self
366 }
367}
LookupSnapshot.swift added +89
@@ -0,0 +1,89 @@
1import Foundation
2
3struct LookupSnapshot {
4 let historyEntryID: UUID?
5 let domain: String
6 let timestamp: Date
7 let trackedDomainID: UUID?
8 let resolverDisplayName: String
9 let resolverURLString: String
10 let totalLookupDurationMs: Int?
11 let dnsSections: [DNSSection]
12 let dnsError: String?
13 let availabilityResult: DomainAvailabilityResult?
14 let suggestions: [DomainSuggestionResult]
15 let sslInfo: SSLCertificateInfo?
16 let sslError: String?
17 let hstsPreloaded: Bool?
18 let httpHeaders: [HTTPHeader]
19 let httpSecurityGrade: String?
20 let httpStatusCode: Int?
21 let httpResponseTimeMs: Int?
22 let httpProtocol: String?
23 let http3Advertised: Bool
24 let httpHeadersError: String?
25 let reachabilityResults: [PortReachability]
26 let reachabilityError: String?
27 let ipGeolocation: IPGeolocation?
28 let ipGeolocationError: String?
29 let emailSecurity: EmailSecurityResult?
30 let emailSecurityError: String?
31 let ownership: DomainOwnership?
32 let ownershipError: String?
33 let ptrRecord: String?
34 let ptrError: String?
35 let redirectChain: [RedirectHop]
36 let redirectChainError: String?
37 let subdomains: [DiscoveredSubdomain]
38 let subdomainsError: String?
39 let portScanResults: [PortScanResult]
40 let portScanError: String?
41 let changeSummary: DomainChangeSummary?
42 let isLive: Bool
43}
44
45extension HistoryEntry {
46 var snapshot: LookupSnapshot {
47 LookupSnapshot(
48 historyEntryID: id,
49 domain: domain,
50 timestamp: timestamp,
51 trackedDomainID: trackedDomainID,
52 resolverDisplayName: resolverDisplayName,
53 resolverURLString: resolverURLString,
54 totalLookupDurationMs: totalLookupDurationMs,
55 dnsSections: dnsSections,
56 dnsError: nil,
57 availabilityResult: availabilityResult,
58 suggestions: suggestions,
59 sslInfo: sslInfo,
60 sslError: sslError,
61 hstsPreloaded: hstsPreloaded,
62 httpHeaders: httpHeaders,
63 httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
64 httpStatusCode: nil,
65 httpResponseTimeMs: nil,
66 httpProtocol: nil,
67 http3Advertised: false,
68 httpHeadersError: httpHeadersError,
69 reachabilityResults: reachabilityResults,
70 reachabilityError: reachabilityError,
71 ipGeolocation: ipGeolocation,
72 ipGeolocationError: ipGeolocationError,
73 emailSecurity: emailSecurity,
74 emailSecurityError: emailSecurityError,
75 ownership: ownership,
76 ownershipError: ownershipError,
77 ptrRecord: ptrRecord,
78 ptrError: ptrError,
79 redirectChain: redirectChain,
80 redirectChainError: redirectChainError,
81 subdomains: subdomains,
82 subdomainsError: subdomainsError,
83 portScanResults: portScanResults,
84 portScanError: portScanError,
85 changeSummary: changeSummary,
86 isLive: false
87 )
88 }
89}