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 @@
6 objectVersion = 77; 6 objectVersion = 77;
7 objects = { 7 objects = {
8 8
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
9/* Begin PBXFileReference section */ 17/* Begin PBXFileReference section */
10 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 18 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>"; };
11/* End PBXFileReference section */ 25/* End PBXFileReference section */
12 26
13/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ 27/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
14 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = { 28 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = {
15 isa = PBXFileSystemSynchronizedBuildFileExceptionSet; 29 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
16 membershipExceptions = ( 30 membershipExceptions = (
31 DomainDigCLI.swift,
17 Info.plist, 32 Info.plist,
18 ); 33 );
19 target = 8B7800682F6090E300933221 /* DomainDig */; 34 target = 8B7800682F6090E300933221 /* DomainDig */;
20 }; 35 };
21/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ 36/* 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 };
22 55
23/* Begin PBXFileSystemSynchronizedRootGroup section */ 56/* Begin PBXFileSystemSynchronizedRootGroup section */
24 8B78006B2F6090E300933221 /* DomainDig */ = { 57 8B78006B2F6090E300933221 /* DomainDig */ = {
25 isa = PBXFileSystemSynchronizedRootGroup; 58 isa = PBXFileSystemSynchronizedRootGroup;
26 exceptions = ( 59 exceptions = (
27 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */, 60 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
61 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */,
28 ); 62 );
29 path = DomainDig; 63 path = DomainDig;
30 sourceTree = "<group>"; 64 sourceTree = "<group>";
@@ -39,6 +73,13 @@
39 ); 73 );
40 runOnlyForDeploymentPostprocessing = 0; 74 runOnlyForDeploymentPostprocessing = 0;
41 }; 75 };
76 8BF124FD2F70000100933221 /* Frameworks */ = {
77 isa = PBXFrameworksBuildPhase;
78 buildActionMask = 2147483647;
79 files = (
80 );
81 runOnlyForDeploymentPostprocessing = 0;
82 };
42/* End PBXFrameworksBuildPhase section */ 83/* End PBXFrameworksBuildPhase section */
43 84
44/* Begin PBXGroup section */ 85/* Begin PBXGroup section */
@@ -47,6 +88,11 @@
47 children = ( 88 children = (
48 8B78006B2F6090E300933221 /* DomainDig */, 89 8B78006B2F6090E300933221 /* DomainDig */,
49 8B78006A2F6090E300933221 /* Products */, 90 8B78006A2F6090E300933221 /* Products */,
91 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
92 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
93 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
94 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
95 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
50 ); 96 );
51 sourceTree = "<group>"; 97 sourceTree = "<group>";
52 }; 98 };
@@ -54,6 +100,7 @@
54 isa = PBXGroup; 100 isa = PBXGroup;
55 children = ( 101 children = (
56 8B7800692F6090E300933221 /* DomainDig.app */, 102 8B7800692F6090E300933221 /* DomainDig.app */,
103 8BF124F92F70000100933221 /* domaindig */,
57 ); 104 );
58 name = Products; 105 name = Products;
59 sourceTree = "<group>"; 106 sourceTree = "<group>";
@@ -83,6 +130,27 @@
83 productReference = 8B7800692F6090E300933221 /* DomainDig.app */; 130 productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
84 productType = "com.apple.product-type.application"; 131 productType = "com.apple.product-type.application";
85 }; 132 };
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 };
86/* End PBXNativeTarget section */ 154/* End PBXNativeTarget section */
87 155
88/* Begin PBXProject section */ 156/* Begin PBXProject section */
@@ -113,6 +181,7 @@
113 projectRoot = ""; 181 projectRoot = "";
114 targets = ( 182 targets = (
115 8B7800682F6090E300933221 /* DomainDig */, 183 8B7800682F6090E300933221 /* DomainDig */,
184 8BF124FB2F70000100933221 /* DomainDigCLI */,
116 ); 185 );
117 }; 186 };
118/* End PBXProject section */ 187/* End PBXProject section */
@@ -132,6 +201,11 @@
132 isa = PBXSourcesBuildPhase; 201 isa = PBXSourcesBuildPhase;
133 buildActionMask = 2147483647; 202 buildActionMask = 2147483647;
134 files = ( 203 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 */,
135 ); 209 );
136 runOnlyForDeploymentPostprocessing = 0; 210 runOnlyForDeploymentPostprocessing = 0;
137 }; 211 };
@@ -267,7 +341,7 @@
267 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 341 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
268 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 342 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
269 CODE_SIGN_STYLE = Automatic; 343 CODE_SIGN_STYLE = Automatic;
270 CURRENT_PROJECT_VERSION = 16; 344 CURRENT_PROJECT_VERSION = 17;
271 DEVELOPMENT_TEAM = ZCNAX3VL9D; 345 DEVELOPMENT_TEAM = ZCNAX3VL9D;
272 ENABLE_PREVIEWS = YES; 346 ENABLE_PREVIEWS = YES;
273 GENERATE_INFOPLIST_FILE = YES; 347 GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +358,7 @@
284 "$(inherited)", 358 "$(inherited)",
285 "@executable_path/Frameworks", 359 "@executable_path/Frameworks",
286 ); 360 );
287 MARKETING_VERSION = 2.3.0; 361 MARKETING_VERSION = 2.4.0;
288 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 362 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
289 PRODUCT_NAME = "$(TARGET_NAME)"; 363 PRODUCT_NAME = "$(TARGET_NAME)";
290 STRING_CATALOG_GENERATE_SYMBOLS = YES; 364 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +377,7 @@
303 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 377 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
304 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 378 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
305 CODE_SIGN_STYLE = Automatic; 379 CODE_SIGN_STYLE = Automatic;
306 CURRENT_PROJECT_VERSION = 16; 380 CURRENT_PROJECT_VERSION = 17;
307 DEVELOPMENT_TEAM = ZCNAX3VL9D; 381 DEVELOPMENT_TEAM = ZCNAX3VL9D;
308 ENABLE_PREVIEWS = YES; 382 ENABLE_PREVIEWS = YES;
309 GENERATE_INFOPLIST_FILE = YES; 383 GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +394,7 @@
320 "$(inherited)", 394 "$(inherited)",
321 "@executable_path/Frameworks", 395 "@executable_path/Frameworks",
322 ); 396 );
323 MARKETING_VERSION = 2.3.0; 397 MARKETING_VERSION = 2.4.0;
324 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 398 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
325 PRODUCT_NAME = "$(TARGET_NAME)"; 399 PRODUCT_NAME = "$(TARGET_NAME)";
326 STRING_CATALOG_GENERATE_SYMBOLS = YES; 400 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -333,6 +407,47 @@
333 }; 407 };
334 name = Release; 408 name = Release;
335 }; 409 };
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 };
336/* End XCBuildConfiguration section */ 451/* End XCBuildConfiguration section */
337 452
338/* Begin XCConfigurationList section */ 453/* Begin XCConfigurationList section */
DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist +5
@@ -5,6 +5,11 @@
5 <key>SchemeUserState</key> 5 <key>SchemeUserState</key>
6 <dict> 6 <dict>
7 <key>DomainDig.xcscheme_^#shared#^_</key> 7 <key>DomainDig.xcscheme_^#shared#^_</key>
8 <dict>
9 <key>orderHint</key>
10 <integer>1</integer>
11 </dict>
12 <key>DomainDigCLI.xcscheme_^#shared#^_</key>
8 <dict> 13 <dict>
9 <key>orderHint</key> 14 <key>orderHint</key>
10 <integer>0</integer> 15 <integer>0</integer>
DomainDig/ContentView.swift +40 −16
@@ -304,10 +304,13 @@ struct ContentView: View {
304 } 304 }
305 Menu { 305 Menu {
306 Button("Export TXT") { 306 Button("Export TXT") {
307 shareSingleResults(asCSV: false) 307 shareSingleResults(format: .text)
308 } 308 }
309 Button("Export CSV") { 309 Button("Export CSV") {
310 shareSingleResults(asCSV: true) 310 shareSingleResults(format: .csv)
311 }
312 Button("Export JSON") {
313 shareSingleResults(format: .json)
311 } 314 }
312 } label: { 315 } label: {
313 Image(systemName: "square.and.arrow.up") 316 Image(systemName: "square.and.arrow.up")
@@ -332,10 +335,13 @@ struct ContentView: View {
332 if !viewModel.currentBatchResultEntries.isEmpty { 335 if !viewModel.currentBatchResultEntries.isEmpty {
333 Menu { 336 Menu {
334 Button("Export Batch TXT") { 337 Button("Export Batch TXT") {
335 shareBatchResults(asCSV: false) 338 shareBatchResults(format: .text)
336 } 339 }
337 Button("Export Batch CSV") { 340 Button("Export Batch CSV") {
338 shareBatchResults(asCSV: true) 341 shareBatchResults(format: .csv)
342 }
343 Button("Export Batch JSON") {
344 shareBatchResults(format: .json)
339 } 345 }
340 } label: { 346 } label: {
341 Label("Export", systemImage: "square.and.arrow.up") 347 Label("Export", systemImage: "square.and.arrow.up")
@@ -412,33 +418,51 @@ struct ContentView: View {
412 return ports 418 return ports
413 } 419 }
414 420
415 private func shareSingleResults(asCSV: Bool) { 421 private func shareSingleResults(format: DomainExportFormat) {
416 let (filename, contents) = exportPayload( 422 let (filename, data) = exportPayload(
417 prefix: "domaindig_single", 423 prefix: "domaindig_single",
424 format: format,
418 text: viewModel.exportText(), 425 text: viewModel.exportText(),
419 csv: viewModel.exportCSV(), 426 csv: viewModel.exportCSV(),
420 asCSV: asCSV 427 json: viewModel.exportJSONData()
421 ) 428 )
422 ExportPresenter.share(filename: filename, contents: contents) 429 ExportPresenter.share(filename: filename, data: data)
423 } 430 }
424 431
425 private func shareBatchResults(asCSV: Bool) { 432 private func shareBatchResults(format: DomainExportFormat) {
426 let (filename, contents) = exportPayload( 433 let (filename, data) = exportPayload(
427 prefix: "domaindig_batch", 434 prefix: "domaindig_batch",
435 format: format,
428 text: viewModel.exportBatchText(), 436 text: viewModel.exportBatchText(),
429 csv: viewModel.exportBatchCSV(), 437 csv: viewModel.exportBatchCSV(),
430 asCSV: asCSV 438 json: viewModel.exportBatchJSONData()
431 ) 439 )
432 ExportPresenter.share(filename: filename, contents: contents) 440 ExportPresenter.share(filename: filename, data: data)
433 } 441 }
434 442
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) {
436 let formatter = DateFormatter() 450 let formatter = DateFormatter()
437 formatter.dateFormat = "yyyyMMdd_HHmmss" 451 formatter.dateFormat = "yyyyMMdd_HHmmss"
438 let timestamp = formatter.string(from: Date()) 452 let timestamp = formatter.string(from: Date())
439 let fileExtension = asCSV ? "csv" : "txt" 453 let filename = "\(timestamp)_\(prefix).\(format.fileExtension)"
440 let filename = "\(timestamp)_\(prefix).\(fileExtension)" 454 let data: Data
441 return (filename, asCSV ? csv : text) 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)
442 } 466 }
443} 467}
444 468
DomainDig/DomainViewModel.swift +69 −361
@@ -91,94 +91,6 @@ struct DomainSuggestionViewData: Identifiable {
91 let tone: ResultTone 91 let tone: ResultTone
92} 92}
93 93
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
182private struct BatchLookupPayload { 94private struct BatchLookupPayload {
183 let snapshot: LookupSnapshot 95 let snapshot: LookupSnapshot
184} 96}
@@ -271,6 +183,7 @@ final class DomainViewModel {
271 private var lookupStartedAt: Date? 183 private var lookupStartedAt: Date?
272 private var activeBatchDomains: [String] = [] 184 private var activeBatchDomains: [String] = []
273 private var lastBatchStartedAt: Date? 185 private var lastBatchStartedAt: Date?
186 private let reportBuilder = DomainReportBuilder()
274 187
275 private static let recentSearchesKey = "recentSearches" 188 private static let recentSearchesKey = "recentSearches"
276 private static let maxRecent = 20 189 private static let maxRecent = 20
@@ -484,6 +397,18 @@ final class DomainViewModel {
484 ) 397 )
485 } 398 }
486 399
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
487 var summaryFields: [SummaryFieldViewData] { 412 var summaryFields: [SummaryFieldViewData] {
488 Self.summaryFields(from: currentSnapshot) 413 Self.summaryFields(from: currentSnapshot)
489 } 414 }
@@ -768,64 +693,55 @@ final class DomainViewModel {
768 } 693 }
769 694
770 func exportText() -> String { 695 func exportText() -> String {
771 Self.formatExportText( 696 guard let currentReport else { return "No results available." }
772 from: currentSnapshot, 697 return DomainReportExporter.text(for: currentReport)
773 trackedDomain: currentTrackedDomain,
774 changeSummary: currentChangeSummary,
775 diffSections: currentDiffSections
776 )
777 } 698 }
778 699
779 func exportCSV() -> String { 700 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)
781 } 708 }
782 709
783 func exportBatchText() -> String { 710 func exportBatchText() -> String {
784 Self.formatBatchExportText( 711 DomainReportExporter.batchText(
785 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export", 712 for: currentBatchReports(),
786 entries: currentBatchResultEntries.map { entry in 713 title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
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 }
797 ) 714 )
798 } 715 }
799 716
800 func exportBatchCSV() -> String { 717 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 )
802 } 727 }
803 728
804 func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String { 729 func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
805 Self.formatCSV(from: exportSnapshots(for: domains)) 730 DomainReportExporter.csv(for: reports(for: domains))
806 } 731 }
807 732
808 func exportTrackedDomainsText(domains: [TrackedDomain]) -> String { 733 func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
809 let latestEntries = latestSnapshots(for: domains) 734 DomainReportExporter.batchText(
810 return Self.formatBatchExportText( 735 for: reports(for: domains),
811 title: "Tracked Domains Export", 736 title: "Tracked Domains Export"
812 entries: domains.map { trackedDomain in 737 )
813 if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) { 738 }
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 }
821 739
822 return ( 740 func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? {
823 snapshot: placeholderSnapshot(for: trackedDomain), 741 try? DomainReportExporter.data(
824 trackedDomain: trackedDomain, 742 for: reports(for: domains),
825 changeSummary: trackedDomain.lastChangeSummary, 743 format: .json,
826 diffSections: [] 744 title: "Tracked Domains Export"
827 )
828 }
829 ) 745 )
830 } 746 }
831 747
@@ -1130,239 +1046,8 @@ final class DomainViewModel {
1130 1046
1131 private static func performBatchLookup(domain: String) async -> BatchLookupPayload? { 1047 private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
1132 guard !Task.isCancelled else { return nil } 1048 guard !Task.isCancelled else { return nil }
1133 1049 let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain)
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
1160 guard !Task.isCancelled else { return nil } 1050 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
1366 return BatchLookupPayload(snapshot: snapshot) 1051 return BatchLookupPayload(snapshot: snapshot)
1367 } 1052 }
1368 1053
@@ -1967,6 +1652,29 @@ final class DomainViewModel {
1967 } 1652 }
1968 } 1653 }
1969 1654
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
1970 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot { 1678 private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
1971 LookupSnapshot( 1679 LookupSnapshot(
1972 historyEntryID: trackedDomain.lastSnapshotID, 1680 historyEntryID: trackedDomain.lastSnapshotID,
DomainDig/ExportPresenter.swift +5 −1
@@ -3,10 +3,14 @@ import UIKit
3 3
4enum ExportPresenter { 4enum ExportPresenter {
5 static func share(filename: String, contents: String) { 5 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) {
6 let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename) 10 let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
7 11
8 do { 12 do {
9 try contents.write(to: url, atomically: true, encoding: .utf8) 13 try data.write(to: url, options: .atomic)
10 } catch { 14 } catch {
11 return 15 return
12 } 16 }
DomainDig/WatchlistView.swift +20 −9
@@ -143,11 +143,15 @@ struct WatchlistView: View {
143 .disabled(viewModel.batchLookupRunning) 143 .disabled(viewModel.batchLookupRunning)
144 144
145 Button("Export TXT") { 145 Button("Export TXT") {
146 shareTrackedDomains(asCSV: false) 146 shareTrackedDomains(format: .text)
147 } 147 }
148 148
149 Button("Export CSV") { 149 Button("Export CSV") {
150 shareTrackedDomains(asCSV: true) 150 shareTrackedDomains(format: .csv)
151 }
152
153 Button("Export JSON") {
154 shareTrackedDomains(format: .json)
151 } 155 }
152 } label: { 156 } label: {
153 Image(systemName: "line.3.horizontal.decrease.circle") 157 Image(systemName: "line.3.horizontal.decrease.circle")
@@ -178,16 +182,23 @@ struct WatchlistView: View {
178 domains.forEach(viewModel.deleteTrackedDomain) 182 domains.forEach(viewModel.deleteTrackedDomain)
179 } 183 }
180 184
181 private func shareTrackedDomains(asCSV: Bool) { 185 private func shareTrackedDomains(format: DomainExportFormat) {
182 let formatter = DateFormatter() 186 let formatter = DateFormatter()
183 formatter.dateFormat = "yyyyMMdd_HHmmss" 187 formatter.dateFormat = "yyyyMMdd_HHmmss"
184 let timestamp = formatter.string(from: Date()) 188 let timestamp = formatter.string(from: Date())
185 let fileExtension = asCSV ? "csv" : "txt" 189 let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
186 let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)" 190 let data: Data
187 let contents = asCSV 191
188 ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains) 192 switch format {
189 : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains) 193 case .text:
190 ExportPresenter.share(filename: filename, contents: contents) 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)
191 } 202 }
192} 203}
193 204
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}