summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--DomainDig.xcodeproj/project.pbxproj123
-rw-r--r--DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist5
-rw-r--r--DomainDig/ContentView.swift56
-rw-r--r--DomainDig/DomainViewModel.swift430
-rw-r--r--DomainDig/ExportPresenter.swift6
-rw-r--r--DomainDig/WatchlistView.swift29
-rw-r--r--DomainDigCLI.swift39
-rw-r--r--DomainInspectionService.swift225
-rw-r--r--DomainReportBuilder.swift190
-rw-r--r--DomainReportExporter.swift367
-rw-r--r--LookupSnapshot.swift89
11 files changed, 1168 insertions, 391 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj
index 6ce65e4..40dadc9 100644
--- a/DomainDig.xcodeproj/project.pbxproj
+++ b/DomainDig.xcodeproj/project.pbxproj
@@ -6,25 +6,59 @@
objectVersion = 77;
objects = {
+/* Begin PBXBuildFile section */
+ 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */; };
+ 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */; };
+ 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */; };
+ 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */; };
+ 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */; };
+/* End PBXBuildFile section */
+
/* Begin PBXFileReference section */
8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8BF124F92F70000100933221 /* domaindig */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = domaindig; sourceTree = BUILT_PRODUCTS_DIR; };
+ 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainDigCLI.swift; sourceTree = "<group>"; };
+ 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainInspectionService.swift; sourceTree = "<group>"; };
+ 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportBuilder.swift; sourceTree = "<group>"; };
+ 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DomainReportExporter.swift; sourceTree = "<group>"; };
+ 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupSnapshot.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
+ DomainDigCLI.swift,
Info.plist,
);
target = 8B7800682F6090E300933221 /* DomainDig */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Assets.xcassets,
+ BatchResultsView.swift,
+ BatchSweepSummaryView.swift,
+ ContentView.swift,
+ DomainDigApp.swift,
+ DomainViewModel.swift,
+ ExportPresenter.swift,
+ HistoryView.swift,
+ Info.plist,
+ LocalNotificationService.swift,
+ SavedDomainsView.swift,
+ WatchlistView.swift,
+ );
+ target = 8BF124FB2F70000100933221 /* DomainDigCLI */;
+ };
/* Begin PBXFileSystemSynchronizedRootGroup section */
8B78006B2F6090E300933221 /* DomainDig */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
+ 8BF124FA2F70000100933221 /* Exceptions for "DomainDig" folder in "DomainDigCLI" target */,
);
path = DomainDig;
sourceTree = "<group>";
@@ -39,6 +73,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 8BF124FD2F70000100933221 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -47,6 +88,11 @@
children = (
8B78006B2F6090E300933221 /* DomainDig */,
8B78006A2F6090E300933221 /* Products */,
+ 8BBFEF062F9874AE00E8E144 /* LookupSnapshot.swift */,
+ 8BBFEF042F9874AE00E8E144 /* DomainReportBuilder.swift */,
+ 8BBFEF032F9874AE00E8E144 /* DomainInspectionService.swift */,
+ 8BBFEF052F9874AE00E8E144 /* DomainReportExporter.swift */,
+ 8BBFEF022F9874AE00E8E144 /* DomainDigCLI.swift */,
);
sourceTree = "<group>";
};
@@ -54,6 +100,7 @@
isa = PBXGroup;
children = (
8B7800692F6090E300933221 /* DomainDig.app */,
+ 8BF124F92F70000100933221 /* domaindig */,
);
name = Products;
sourceTree = "<group>";
@@ -83,6 +130,27 @@
productReference = 8B7800692F6090E300933221 /* DomainDig.app */;
productType = "com.apple.product-type.application";
};
+ 8BF124FB2F70000100933221 /* DomainDigCLI */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 8BF125042F70000100933221 /* Build configuration list for PBXNativeTarget "DomainDigCLI" */;
+ buildPhases = (
+ 8BF124FC2F70000100933221 /* Sources */,
+ 8BF124FD2F70000100933221 /* Frameworks */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 8B78006B2F6090E300933221 /* DomainDig */,
+ );
+ name = DomainDigCLI;
+ packageProductDependencies = (
+ );
+ productName = domaindig;
+ productReference = 8BF124F92F70000100933221 /* domaindig */;
+ productType = "com.apple.product-type.tool";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -113,6 +181,7 @@
projectRoot = "";
targets = (
8B7800682F6090E300933221 /* DomainDig */,
+ 8BF124FB2F70000100933221 /* DomainDigCLI */,
);
};
/* End PBXProject section */
@@ -132,6 +201,11 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ 8BBFEF082F9874AE00E8E144 /* DomainDigCLI.swift in Sources */,
+ 8BBFEF092F9874AE00E8E144 /* DomainInspectionService.swift in Sources */,
+ 8BBFEF0A2F9874AE00E8E144 /* DomainReportBuilder.swift in Sources */,
+ 8BBFEF0B2F9874AE00E8E144 /* DomainReportExporter.swift in Sources */,
+ 8BBFEF0C2F9874AE00E8E144 /* LookupSnapshot.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -267,7 +341,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 16;
+ CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -284,7 +358,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.3.0;
+ MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -303,7 +377,7 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
CODE_SIGN_STYLE = Automatic;
- CURRENT_PROJECT_VERSION = 16;
+ CURRENT_PROJECT_VERSION = 17;
DEVELOPMENT_TEAM = ZCNAX3VL9D;
ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES;
@@ -320,7 +394,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
- MARKETING_VERSION = 2.3.0;
+ MARKETING_VERSION = 2.4.0;
PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -333,6 +407,47 @@
};
name = Release;
};
+ 8BF125002F70000100933221 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 17;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ ENABLE_TESTABILITY = YES;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 2.4.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
+ PRODUCT_NAME = domaindig;
+ SDKROOT = macosx;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 8BF125012F70000100933221 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGNING_ALLOWED = NO;
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 17;
+ DEVELOPMENT_TEAM = ZCNAX3VL9D;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 14.0;
+ MARKETING_VERSION = 2.4.0;
+ PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDigCLI;
+ PRODUCT_NAME = domaindig;
+ SDKROOT = macosx;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = NO;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
diff --git a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
index bd2f39c..06d0111 100644
--- a/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/DomainDig.xcodeproj/xcuserdata/cmc.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -7,6 +7,11 @@
<key>DomainDig.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
+ <integer>1</integer>
+ </dict>
+ <key>DomainDigCLI.xcscheme_^#shared#^_</key>
+ <dict>
+ <key>orderHint</key>
<integer>0</integer>
</dict>
</dict>
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index 261b7ca..c5fc1fa 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -304,10 +304,13 @@ struct ContentView: View {
}
Menu {
Button("Export TXT") {
- shareSingleResults(asCSV: false)
+ shareSingleResults(format: .text)
}
Button("Export CSV") {
- shareSingleResults(asCSV: true)
+ shareSingleResults(format: .csv)
+ }
+ Button("Export JSON") {
+ shareSingleResults(format: .json)
}
} label: {
Image(systemName: "square.and.arrow.up")
@@ -332,10 +335,13 @@ struct ContentView: View {
if !viewModel.currentBatchResultEntries.isEmpty {
Menu {
Button("Export Batch TXT") {
- shareBatchResults(asCSV: false)
+ shareBatchResults(format: .text)
}
Button("Export Batch CSV") {
- shareBatchResults(asCSV: true)
+ shareBatchResults(format: .csv)
+ }
+ Button("Export Batch JSON") {
+ shareBatchResults(format: .json)
}
} label: {
Label("Export", systemImage: "square.and.arrow.up")
@@ -412,33 +418,51 @@ struct ContentView: View {
return ports
}
- private func shareSingleResults(asCSV: Bool) {
- let (filename, contents) = exportPayload(
+ private func shareSingleResults(format: DomainExportFormat) {
+ let (filename, data) = exportPayload(
prefix: "domaindig_single",
+ format: format,
text: viewModel.exportText(),
csv: viewModel.exportCSV(),
- asCSV: asCSV
+ json: viewModel.exportJSONData()
)
- ExportPresenter.share(filename: filename, contents: contents)
+ ExportPresenter.share(filename: filename, data: data)
}
- private func shareBatchResults(asCSV: Bool) {
- let (filename, contents) = exportPayload(
+ private func shareBatchResults(format: DomainExportFormat) {
+ let (filename, data) = exportPayload(
prefix: "domaindig_batch",
+ format: format,
text: viewModel.exportBatchText(),
csv: viewModel.exportBatchCSV(),
- asCSV: asCSV
+ json: viewModel.exportBatchJSONData()
)
- ExportPresenter.share(filename: filename, contents: contents)
+ ExportPresenter.share(filename: filename, data: data)
}
- private func exportPayload(prefix: String, text: String, csv: String, asCSV: Bool) -> (String, String) {
+ private func exportPayload(
+ prefix: String,
+ format: DomainExportFormat,
+ text: String,
+ csv: String,
+ json: Data?
+ ) -> (String, Data) {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let timestamp = formatter.string(from: Date())
- let fileExtension = asCSV ? "csv" : "txt"
- let filename = "\(timestamp)_\(prefix).\(fileExtension)"
- return (filename, asCSV ? csv : text)
+ let filename = "\(timestamp)_\(prefix).\(format.fileExtension)"
+ let data: Data
+
+ switch format {
+ case .text:
+ data = Data(text.utf8)
+ case .csv:
+ data = Data(csv.utf8)
+ case .json:
+ data = json ?? Data("[]".utf8)
+ }
+
+ return (filename, data)
}
}
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index e895ce6..71209dc 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -91,94 +91,6 @@ struct DomainSuggestionViewData: Identifiable {
let tone: ResultTone
}
-struct LookupSnapshot {
- let historyEntryID: UUID?
- let domain: String
- let timestamp: Date
- let trackedDomainID: UUID?
- let resolverDisplayName: String
- let resolverURLString: String
- let totalLookupDurationMs: Int?
- let dnsSections: [DNSSection]
- let dnsError: String?
- let availabilityResult: DomainAvailabilityResult?
- let suggestions: [DomainSuggestionResult]
- let sslInfo: SSLCertificateInfo?
- let sslError: String?
- let hstsPreloaded: Bool?
- let httpHeaders: [HTTPHeader]
- let httpSecurityGrade: String?
- let httpStatusCode: Int?
- let httpResponseTimeMs: Int?
- let httpProtocol: String?
- let http3Advertised: Bool
- let httpHeadersError: String?
- let reachabilityResults: [PortReachability]
- let reachabilityError: String?
- let ipGeolocation: IPGeolocation?
- let ipGeolocationError: String?
- let emailSecurity: EmailSecurityResult?
- let emailSecurityError: String?
- let ownership: DomainOwnership?
- let ownershipError: String?
- let ptrRecord: String?
- let ptrError: String?
- let redirectChain: [RedirectHop]
- let redirectChainError: String?
- let subdomains: [DiscoveredSubdomain]
- let subdomainsError: String?
- let portScanResults: [PortScanResult]
- let portScanError: String?
- let changeSummary: DomainChangeSummary?
- let isLive: Bool
-}
-
-extension HistoryEntry {
- var snapshot: LookupSnapshot {
- LookupSnapshot(
- historyEntryID: id,
- domain: domain,
- timestamp: timestamp,
- trackedDomainID: trackedDomainID,
- resolverDisplayName: resolverDisplayName,
- resolverURLString: resolverURLString,
- totalLookupDurationMs: totalLookupDurationMs,
- dnsSections: dnsSections,
- dnsError: nil,
- availabilityResult: availabilityResult,
- suggestions: suggestions,
- sslInfo: sslInfo,
- sslError: sslError,
- hstsPreloaded: hstsPreloaded,
- httpHeaders: httpHeaders,
- httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
- httpStatusCode: nil,
- httpResponseTimeMs: nil,
- httpProtocol: nil,
- http3Advertised: false,
- httpHeadersError: httpHeadersError,
- reachabilityResults: reachabilityResults,
- reachabilityError: reachabilityError,
- ipGeolocation: ipGeolocation,
- ipGeolocationError: ipGeolocationError,
- emailSecurity: emailSecurity,
- emailSecurityError: emailSecurityError,
- ownership: ownership,
- ownershipError: ownershipError,
- ptrRecord: ptrRecord,
- ptrError: ptrError,
- redirectChain: redirectChain,
- redirectChainError: redirectChainError,
- subdomains: subdomains,
- subdomainsError: subdomainsError,
- portScanResults: portScanResults,
- portScanError: portScanError,
- changeSummary: changeSummary,
- isLive: false
- )
- }
-}
-
private struct BatchLookupPayload {
let snapshot: LookupSnapshot
}
@@ -271,6 +183,7 @@ final class DomainViewModel {
private var lookupStartedAt: Date?
private var activeBatchDomains: [String] = []
private var lastBatchStartedAt: Date?
+ private let reportBuilder = DomainReportBuilder()
private static let recentSearchesKey = "recentSearches"
private static let maxRecent = 20
@@ -484,6 +397,18 @@ final class DomainViewModel {
)
}
+ var currentReport: DomainReport? {
+ guard !searchedDomain.isEmpty else { return nil }
+ return reportBuilder.build(
+ from: currentSnapshot,
+ previousSnapshot: previousSnapshot(
+ for: searchedDomain,
+ trackedDomainID: currentTrackedDomain?.id,
+ replacingLatest: false
+ )
+ )
+ }
+
var summaryFields: [SummaryFieldViewData] {
Self.summaryFields(from: currentSnapshot)
}
@@ -768,64 +693,55 @@ final class DomainViewModel {
}
func exportText() -> String {
- Self.formatExportText(
- from: currentSnapshot,
- trackedDomain: currentTrackedDomain,
- changeSummary: currentChangeSummary,
- diffSections: currentDiffSections
- )
+ guard let currentReport else { return "No results available." }
+ return DomainReportExporter.text(for: currentReport)
}
func exportCSV() -> String {
- Self.formatCSV(from: [currentSnapshot])
+ guard let currentReport else { return DomainReportExporter.csv(for: []) }
+ return DomainReportExporter.csv(for: [currentReport])
+ }
+
+ func exportJSONData() -> Data? {
+ guard let currentReport else { return nil }
+ return try? DomainReportExporter.data(for: currentReport, format: .json)
}
func exportBatchText() -> String {
- Self.formatBatchExportText(
- title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export",
- entries: currentBatchResultEntries.map { entry in
- (
- snapshot: entry.snapshot,
- trackedDomain: trackedDomains.first(where: { tracked in
- tracked.id == entry.trackedDomainID ||
- tracked.domain.caseInsensitiveCompare(entry.domain) == .orderedSame
- }),
- changeSummary: entry.changeSummary,
- diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
- )
- }
+ DomainReportExporter.batchText(
+ for: currentBatchReports(),
+ title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
)
}
func exportBatchCSV() -> String {
- Self.formatCSV(from: currentBatchResultEntries.map(\.snapshot))
+ DomainReportExporter.csv(for: currentBatchReports())
+ }
+
+ func exportBatchJSONData() -> Data? {
+ try? DomainReportExporter.data(
+ for: currentBatchReports(),
+ format: .json,
+ title: batchLookupSource == .watchlistRefresh ? "Tracked Domains Export" : "Batch Results Export"
+ )
}
func exportTrackedDomainsCSV(domains: [TrackedDomain]) -> String {
- Self.formatCSV(from: exportSnapshots(for: domains))
+ DomainReportExporter.csv(for: reports(for: domains))
}
func exportTrackedDomainsText(domains: [TrackedDomain]) -> String {
- let latestEntries = latestSnapshots(for: domains)
- return Self.formatBatchExportText(
- title: "Tracked Domains Export",
- entries: domains.map { trackedDomain in
- if let entry = latestEntries.first(where: { $0.trackedDomainID == trackedDomain.id || $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame }) {
- return (
- snapshot: entry.snapshot,
- trackedDomain: trackedDomain,
- changeSummary: entry.changeSummary,
- diffSections: comparisonSnapshot(for: entry).map { DomainDiffService.diff(from: $0, to: entry.snapshot) } ?? []
- )
- }
+ DomainReportExporter.batchText(
+ for: reports(for: domains),
+ title: "Tracked Domains Export"
+ )
+ }
- return (
- snapshot: placeholderSnapshot(for: trackedDomain),
- trackedDomain: trackedDomain,
- changeSummary: trackedDomain.lastChangeSummary,
- diffSections: []
- )
- }
+ func exportTrackedDomainsJSONData(domains: [TrackedDomain]) -> Data? {
+ try? DomainReportExporter.data(
+ for: reports(for: domains),
+ format: .json,
+ title: "Tracked Domains Export"
)
}
@@ -1130,239 +1046,8 @@ final class DomainViewModel {
private static func performBatchLookup(domain: String) async -> BatchLookupPayload? {
guard !Task.isCancelled else { return nil }
-
- let startedAt = Date()
- let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
- let resolverURLString = DNSLookupService.currentResolverURLString()
-
- async let dnsResult = DNSLookupService.lookupAll(domain: domain)
- async let availabilityResult = DomainAvailabilityService.check(domain: domain)
- async let sslResult = SSLCheckService.check(domain: domain)
- async let hstsResult = SSLCheckService.checkHSTSPreload(domain: domain)
- async let httpResult = HTTPHeadersService.fetch(domain: domain)
- async let reachabilityResult = ReachabilityService.checkAll(domain: domain)
- async let ownershipResult = DomainOwnershipService.lookup(domain: domain)
- async let redirectResult = RedirectChainService.trace(domain: domain)
- async let subdomainResult = SubdomainDiscoveryService.discover(for: domain)
- async let portScanResult = PortScanService.scanAll(domain: domain)
-
- let resolvedDNS = await dnsResult
- let availability = await availabilityResult
- let resolvedSSL = await sslResult
- let hsts = await hstsResult
- let http = await httpResult
- let reachability = await reachabilityResult
- let resolvedOwnership = await ownershipResult
- let redirects = await redirectResult
- let resolvedSubdomains = await subdomainResult
- let ports = await portScanResult
-
+ let snapshot = await DomainInspectionService().inspectSnapshot(domain: domain)
guard !Task.isCancelled else { return nil }
-
- let dnsSections: [DNSSection]
- let dnsError: String?
- switch resolvedDNS {
- case let .success(sections):
- dnsSections = sections
- dnsError = nil
- case let .empty(message), let .error(message):
- dnsSections = []
- dnsError = message
- }
-
- let sslInfo: SSLCertificateInfo?
- let sslError: String?
- switch resolvedSSL {
- case let .success(info):
- sslInfo = info
- sslError = nil
- case let .empty(message), let .error(message):
- sslInfo = nil
- sslError = message
- }
-
- let httpHeaders: [HTTPHeader]
- let httpSecurityGrade: String?
- let httpStatusCode: Int?
- let httpResponseTimeMs: Int?
- let httpProtocol: String?
- let http3Advertised: Bool
- let httpHeadersError: String?
- switch http {
- case let .success(result):
- httpHeaders = result.headers
- httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
- httpStatusCode = result.statusCode
- httpResponseTimeMs = result.responseTimeMs
- httpProtocol = result.httpProtocol
- http3Advertised = result.http3Advertised
- httpHeadersError = nil
- case let .empty(message), let .error(message):
- httpHeaders = []
- httpSecurityGrade = nil
- httpStatusCode = nil
- httpResponseTimeMs = nil
- httpProtocol = nil
- http3Advertised = false
- httpHeadersError = message
- }
-
- let reachabilityResults: [PortReachability]
- let reachabilityError: String?
- switch reachability {
- case let .success(results):
- reachabilityResults = results
- reachabilityError = nil
- case let .empty(message), let .error(message):
- reachabilityResults = []
- reachabilityError = message
- }
-
- let redirectChain: [RedirectHop]
- let redirectChainError: String?
- switch redirects {
- case let .success(hops):
- redirectChain = hops
- redirectChainError = nil
- case let .empty(message), let .error(message):
- redirectChain = []
- redirectChainError = message
- }
-
- let portScanResults: [PortScanResult]
- let portScanError: String?
- switch ports {
- case let .success(results):
- portScanResults = await enrichOpenPortBanners(results, domain: domain)
- portScanError = nil
- case let .empty(message), let .error(message):
- portScanResults = []
- portScanError = message
- }
-
- let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
- let primaryIP = dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
-
- async let emailResult = EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
- async let suggestions = availability.status == .registered ? DomainAvailabilityService.suggestions(for: domain) : []
-
- let resolvedEmail = await emailResult
- let resolvedSuggestions = await suggestions
- let resolvedPTR: ServiceResult<String>?
- let resolvedGeo: ServiceResult<IPGeolocation>?
- if let primaryIP {
- resolvedPTR = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
- resolvedGeo = await IPGeolocationService.lookup(ip: primaryIP)
- } else {
- resolvedPTR = nil
- resolvedGeo = nil
- }
-
- guard !Task.isCancelled else { return nil }
-
- let emailSecurity: EmailSecurityResult?
- let emailSecurityError: String?
- switch resolvedEmail {
- case let .success(result):
- emailSecurity = result
- emailSecurityError = nil
- case let .empty(message), let .error(message):
- emailSecurity = nil
- emailSecurityError = message
- }
-
- let ownership: DomainOwnership?
- let ownershipError: String?
- switch resolvedOwnership {
- case let .success(result):
- ownership = result
- ownershipError = nil
- case let .empty(message), let .error(message):
- ownership = nil
- ownershipError = message
- }
-
- let ptrRecord: String?
- let ptrError: String?
- switch resolvedPTR {
- case let .success(record):
- ptrRecord = record
- ptrError = nil
- case let .empty(message), let .error(message):
- ptrRecord = nil
- ptrError = message
- case .none:
- ptrRecord = nil
- ptrError = "No A record available"
- }
-
- let ipGeolocation: IPGeolocation?
- let ipGeolocationError: String?
- switch resolvedGeo {
- case let .success(result):
- ipGeolocation = result
- ipGeolocationError = nil
- case let .empty(message), let .error(message):
- ipGeolocation = nil
- ipGeolocationError = message
- case .none:
- ipGeolocation = nil
- ipGeolocationError = "No A record available"
- }
-
- let subdomains: [DiscoveredSubdomain]
- let subdomainsError: String?
- switch resolvedSubdomains {
- case let .success(result):
- subdomains = result
- subdomainsError = nil
- case let .empty(message), let .error(message):
- subdomains = []
- subdomainsError = message
- }
-
- let snapshot = LookupSnapshot(
- historyEntryID: nil,
- domain: domain,
- timestamp: Date(),
- trackedDomainID: nil,
- resolverDisplayName: resolverDisplayName,
- resolverURLString: resolverURLString,
- totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
- dnsSections: dnsSections,
- dnsError: dnsError,
- availabilityResult: availability,
- suggestions: resolvedSuggestions,
- sslInfo: sslInfo,
- sslError: sslError,
- hstsPreloaded: hsts,
- httpHeaders: httpHeaders,
- httpSecurityGrade: httpSecurityGrade,
- httpStatusCode: httpStatusCode,
- httpResponseTimeMs: httpResponseTimeMs,
- httpProtocol: httpProtocol,
- http3Advertised: http3Advertised,
- httpHeadersError: httpHeadersError,
- reachabilityResults: reachabilityResults,
- reachabilityError: reachabilityError,
- ipGeolocation: ipGeolocation,
- ipGeolocationError: ipGeolocationError,
- emailSecurity: emailSecurity,
- emailSecurityError: emailSecurityError,
- ownership: ownership,
- ownershipError: ownershipError,
- ptrRecord: ptrRecord,
- ptrError: ptrError,
- redirectChain: redirectChain,
- redirectChainError: redirectChainError,
- subdomains: subdomains,
- subdomainsError: subdomainsError,
- portScanResults: portScanResults,
- portScanError: portScanError,
- changeSummary: nil,
- isLive: false
- )
-
return BatchLookupPayload(snapshot: snapshot)
}
@@ -1967,6 +1652,29 @@ final class DomainViewModel {
}
}
+ private func currentBatchReports() -> [DomainReport] {
+ currentBatchResultEntries.map(report(for:))
+ }
+
+ private func reports(for domains: [TrackedDomain]) -> [DomainReport] {
+ let latestEntries = latestSnapshots(for: domains)
+
+ return domains.map { trackedDomain in
+ if let entry = latestEntries.first(where: {
+ $0.trackedDomainID == trackedDomain.id ||
+ $0.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
+ }) {
+ return report(for: entry)
+ }
+
+ return reportBuilder.build(from: placeholderSnapshot(for: trackedDomain))
+ }
+ }
+
+ private func report(for entry: HistoryEntry) -> DomainReport {
+ reportBuilder.build(from: entry, previousSnapshot: comparisonSnapshot(for: entry))
+ }
+
private func placeholderSnapshot(for trackedDomain: TrackedDomain) -> LookupSnapshot {
LookupSnapshot(
historyEntryID: trackedDomain.lastSnapshotID,
diff --git a/DomainDig/ExportPresenter.swift b/DomainDig/ExportPresenter.swift
index 82ec7ae..2dbd1fc 100644
--- a/DomainDig/ExportPresenter.swift
+++ b/DomainDig/ExportPresenter.swift
@@ -3,10 +3,14 @@ import UIKit
enum ExportPresenter {
static func share(filename: String, contents: String) {
+ share(filename: filename, data: Data(contents.utf8))
+ }
+
+ static func share(filename: String, data: Data) {
let url = FileManager.default.temporaryDirectory.appendingPathComponent(filename)
do {
- try contents.write(to: url, atomically: true, encoding: .utf8)
+ try data.write(to: url, options: .atomic)
} catch {
return
}
diff --git a/DomainDig/WatchlistView.swift b/DomainDig/WatchlistView.swift
index 1f03a0d..7e42b53 100644
--- a/DomainDig/WatchlistView.swift
+++ b/DomainDig/WatchlistView.swift
@@ -143,11 +143,15 @@ struct WatchlistView: View {
.disabled(viewModel.batchLookupRunning)
Button("Export TXT") {
- shareTrackedDomains(asCSV: false)
+ shareTrackedDomains(format: .text)
}
Button("Export CSV") {
- shareTrackedDomains(asCSV: true)
+ shareTrackedDomains(format: .csv)
+ }
+
+ Button("Export JSON") {
+ shareTrackedDomains(format: .json)
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
@@ -178,16 +182,23 @@ struct WatchlistView: View {
domains.forEach(viewModel.deleteTrackedDomain)
}
- private func shareTrackedDomains(asCSV: Bool) {
+ private func shareTrackedDomains(format: DomainExportFormat) {
let formatter = DateFormatter()
formatter.dateFormat = "yyyyMMdd_HHmmss"
let timestamp = formatter.string(from: Date())
- let fileExtension = asCSV ? "csv" : "txt"
- let filename = "\(timestamp)_domaindig_watchlist.\(fileExtension)"
- let contents = asCSV
- ? viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains)
- : viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains)
- ExportPresenter.share(filename: filename, contents: contents)
+ let filename = "\(timestamp)_domaindig_watchlist.\(format.fileExtension)"
+ let data: Data
+
+ switch format {
+ case .text:
+ data = Data(viewModel.exportTrackedDomainsText(domains: viewModel.filteredTrackedDomains).utf8)
+ case .csv:
+ data = Data(viewModel.exportTrackedDomainsCSV(domains: viewModel.filteredTrackedDomains).utf8)
+ case .json:
+ data = viewModel.exportTrackedDomainsJSONData(domains: viewModel.filteredTrackedDomains) ?? Data("[]".utf8)
+ }
+
+ ExportPresenter.share(filename: filename, data: data)
}
}
diff --git a/DomainDigCLI.swift b/DomainDigCLI.swift
new file mode 100644
index 0000000..73f7028
--- /dev/null
+++ b/DomainDigCLI.swift
@@ -0,0 +1,39 @@
+import Foundation
+
+@main
+struct DomainDigCLI {
+ static func main() async {
+ let arguments = Array(CommandLine.arguments.dropFirst())
+
+ guard let command = CommandLine.arguments.first else {
+ fputs("usage: domaindig <domain> [--json]\n", stderr)
+ Foundation.exit(1)
+ }
+ _ = command
+
+ let wantsJSON = arguments.contains("--json") || arguments.contains("-j")
+ let domains = arguments.filter { !$0.hasPrefix("-") }
+
+ guard let domain = domains.first, !domain.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
+ fputs("usage: domaindig <domain> [--json]\n", stderr)
+ Foundation.exit(1)
+ }
+
+ let inspectionService = DomainInspectionService()
+ let report = await inspectionService.inspect(domain: domain)
+
+ do {
+ let data = try DomainReportExporter.data(
+ for: report,
+ format: wantsJSON ? .json : .text
+ )
+ FileHandle.standardOutput.write(data)
+ if data.last != 0x0A {
+ FileHandle.standardOutput.write(Data([0x0A]))
+ }
+ } catch {
+ fputs("domaindig: \(error.localizedDescription)\n", stderr)
+ Foundation.exit(1)
+ }
+ }
+}
diff --git a/DomainInspectionService.swift b/DomainInspectionService.swift
new file mode 100644
index 0000000..3f11290
--- /dev/null
+++ b/DomainInspectionService.swift
@@ -0,0 +1,225 @@
+import Foundation
+
+struct DomainInspectionService {
+ private let reportBuilder = DomainReportBuilder()
+
+ func inspect(domain: String) async -> DomainReport {
+ let snapshot = await inspectSnapshot(domain: domain)
+ return reportBuilder.build(from: snapshot)
+ }
+
+ func inspectSnapshot(domain: String) async -> LookupSnapshot {
+ let normalizedDomain = normalize(domain)
+ let startedAt = Date()
+ let resolverDisplayName = DNSLookupService.currentResolverDisplayName()
+ let resolverURLString = DNSLookupService.currentResolverURLString()
+
+ async let dnsResult = DNSLookupService.lookupAll(domain: normalizedDomain)
+ async let availabilityResult = DomainAvailabilityService.check(domain: normalizedDomain)
+ async let sslResult = SSLCheckService.check(domain: normalizedDomain)
+ async let hstsResult = SSLCheckService.checkHSTSPreload(domain: normalizedDomain)
+ async let httpResult = HTTPHeadersService.fetch(domain: normalizedDomain)
+ async let reachabilityResult = ReachabilityService.checkAll(domain: normalizedDomain)
+ async let ownershipResult = DomainOwnershipService.lookup(domain: normalizedDomain)
+ async let redirectResult = RedirectChainService.trace(domain: normalizedDomain)
+ async let subdomainResult = SubdomainDiscoveryService.discover(for: normalizedDomain)
+ async let portScanResult = PortScanService.scanAll(domain: normalizedDomain)
+
+ let resolvedDNS = await dnsResult
+ let availability = await availabilityResult
+ let resolvedSSL = await sslResult
+ let hsts = await hstsResult
+ let http = await httpResult
+ let reachability = await reachabilityResult
+ let resolvedOwnership = await ownershipResult
+ let redirects = await redirectResult
+ let resolvedSubdomains = await subdomainResult
+ let ports = await portScanResult
+
+ let dnsSections = mapServiceResult(resolvedDNS, emptyValue: [])
+ let sslInfo = mapOptionalValueServiceResult(resolvedSSL)
+ let httpHeadersResult = mapHTTPResult(http)
+ let reachabilityResultValue = mapServiceResult(reachability, emptyValue: [])
+ let redirectChain = mapServiceResult(redirects, emptyValue: [])
+ let ownership = mapOptionalValueServiceResult(resolvedOwnership)
+ let subdomains = mapServiceResult(resolvedSubdomains, emptyValue: [])
+ let portScanResults = await mapPortScanResult(ports, domain: normalizedDomain)
+
+ let txtRecords = dnsSections.value.first(where: { $0.recordType == .TXT })?.records ?? []
+ let primaryIP = dnsSections.value.first(where: { $0.recordType == .A })?.records.first?.value
+
+ async let emailResult = EmailSecurityService.analyze(domain: normalizedDomain, txtRecords: txtRecords)
+ async let suggestions = availability.status == .registered
+ ? DomainAvailabilityService.suggestions(for: normalizedDomain)
+ : []
+
+ let resolvedEmail = await emailResult
+ let resolvedSuggestions = await suggestions
+
+ let ptrResult: ServiceResult<String>?
+ let geoResult: ServiceResult<IPGeolocation>?
+ if let primaryIP {
+ ptrResult = await ReverseDNSService.lookup(ip: primaryIP, resolverURLString: resolverURLString)
+ geoResult = await IPGeolocationService.lookup(ip: primaryIP)
+ } else {
+ ptrResult = nil
+ geoResult = nil
+ }
+
+ let emailSecurity = mapOptionalValueServiceResult(resolvedEmail)
+ let ptrRecord = mapOptionalServiceResult(ptrResult, missingMessage: "No A record available")
+ let geolocation = mapOptionalServiceResult(geoResult, missingMessage: "No A record available")
+
+ return LookupSnapshot(
+ historyEntryID: nil,
+ domain: availability.domain,
+ timestamp: Date(),
+ trackedDomainID: nil,
+ resolverDisplayName: resolverDisplayName,
+ resolverURLString: resolverURLString,
+ totalLookupDurationMs: Int(Date().timeIntervalSince(startedAt) * 1000),
+ dnsSections: dnsSections.value,
+ dnsError: dnsSections.message,
+ availabilityResult: availability,
+ suggestions: resolvedSuggestions,
+ sslInfo: sslInfo.value,
+ sslError: sslInfo.message,
+ hstsPreloaded: hsts,
+ httpHeaders: httpHeadersResult.headers,
+ httpSecurityGrade: httpHeadersResult.securityGrade,
+ httpStatusCode: httpHeadersResult.statusCode,
+ httpResponseTimeMs: httpHeadersResult.responseTimeMs,
+ httpProtocol: httpHeadersResult.httpProtocol,
+ http3Advertised: httpHeadersResult.http3Advertised,
+ httpHeadersError: httpHeadersResult.error,
+ reachabilityResults: reachabilityResultValue.value,
+ reachabilityError: reachabilityResultValue.message,
+ ipGeolocation: geolocation.value,
+ ipGeolocationError: geolocation.message,
+ emailSecurity: emailSecurity.value,
+ emailSecurityError: emailSecurity.message,
+ ownership: ownership.value,
+ ownershipError: ownership.message,
+ ptrRecord: ptrRecord.value,
+ ptrError: ptrRecord.message,
+ redirectChain: redirectChain.value,
+ redirectChainError: redirectChain.message,
+ subdomains: subdomains.value,
+ subdomainsError: subdomains.message,
+ portScanResults: portScanResults.value,
+ portScanError: portScanResults.message,
+ changeSummary: nil,
+ isLive: false
+ )
+ }
+
+ private func normalize(_ domain: String) -> String {
+ domain
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ .replacingOccurrences(of: "https://", with: "")
+ .replacingOccurrences(of: "http://", with: "")
+ .components(separatedBy: "/").first?
+ .lowercased() ?? domain.lowercased()
+ }
+
+ private func mapServiceResult<Value>(_ result: ServiceResult<Value>, emptyValue: Value) -> (value: Value, message: String?) {
+ switch result {
+ case let .success(value):
+ return (value, nil)
+ case let .empty(message), let .error(message):
+ return (emptyValue, message)
+ }
+ }
+
+ private func mapOptionalValueServiceResult<Value>(_ result: ServiceResult<Value>) -> (value: Value?, message: String?) {
+ switch result {
+ case let .success(value):
+ return (value, nil)
+ case let .empty(message), let .error(message):
+ return (nil, message)
+ }
+ }
+
+ private func mapOptionalServiceResult<Value>(
+ _ result: ServiceResult<Value>?,
+ missingMessage: String
+ ) -> (value: Value?, message: String?) {
+ guard let result else {
+ return (nil, missingMessage)
+ }
+
+ switch result {
+ case let .success(value):
+ return (value, nil)
+ case let .empty(message), let .error(message):
+ return (nil, message)
+ }
+ }
+
+ private func mapPortScanResult(_ result: ServiceResult<[PortScanResult]>, domain: String) async -> (value: [PortScanResult], message: String?) {
+ switch result {
+ case let .success(results):
+ return (await enrichOpenPortBanners(in: results, domain: domain), nil)
+ case let .empty(message), let .error(message):
+ return ([], message)
+ }
+ }
+
+ private func mapHTTPResult(_ result: ServiceResult<HTTPHeadersResult>) -> (
+ headers: [HTTPHeader],
+ securityGrade: String?,
+ statusCode: Int?,
+ responseTimeMs: Int?,
+ httpProtocol: String?,
+ http3Advertised: Bool,
+ error: String?
+ ) {
+ switch result {
+ case let .success(value):
+ return (
+ headers: value.headers,
+ securityGrade: HTTPSecurityGrade.grade(for: value.headers).rawValue,
+ statusCode: value.statusCode,
+ responseTimeMs: value.responseTimeMs,
+ httpProtocol: value.httpProtocol,
+ http3Advertised: value.http3Advertised,
+ error: nil
+ )
+ case let .empty(message), let .error(message):
+ return (
+ headers: [],
+ securityGrade: nil,
+ statusCode: nil,
+ responseTimeMs: nil,
+ httpProtocol: nil,
+ http3Advertised: false,
+ error: message
+ )
+ }
+ }
+
+ private func enrichOpenPortBanners(in results: [PortScanResult], domain: String) async -> [PortScanResult] {
+ let banners = await withTaskGroup(of: (UInt16, String?).self, returning: [UInt16: String].self) { group in
+ for result in results where result.open {
+ group.addTask {
+ let banner = await PortScanService.grabBanner(host: domain, port: result.port)
+ return (result.port, banner)
+ }
+ }
+
+ var collected: [UInt16: String] = [:]
+ for await (port, banner) in group {
+ if let banner {
+ collected[port] = banner
+ }
+ }
+ return collected
+ }
+
+ return results.map { result in
+ var updated = result
+ updated.banner = banners[result.port]
+ return updated
+ }
+ }
+}
diff --git a/DomainReportBuilder.swift b/DomainReportBuilder.swift
new file mode 100644
index 0000000..8cb5c82
--- /dev/null
+++ b/DomainReportBuilder.swift
@@ -0,0 +1,190 @@
+import Foundation
+
+struct DomainReport: Codable {
+ let domain: String
+ let timestamp: Date
+ let availability: DomainAvailabilityStatus
+ let ownership: DomainOwnership?
+ let dns: DNSResultSummary
+ let web: WebResultSummary
+ let email: EmailSecuritySummary
+ let network: NetworkSummary
+ let subdomains: [String]
+ let changeSummary: DomainChangeSummary?
+}
+
+struct DNSResultSummary: Codable {
+ let resolverDisplayName: String
+ let resolverURLString: String
+ let lookupDurationMs: Int?
+ let recordSections: [DNSSection]
+ let primaryIP: String?
+ let ptrRecord: String?
+ let dnssecSigned: Bool?
+ let error: String?
+ let ptrError: String?
+}
+
+struct WebResultSummary: Codable {
+ let tls: SSLCertificateInfo?
+ let tlsStatus: String
+ let certificateWarningLevel: CertificateWarningLevel
+ let hstsPreloaded: Bool?
+ let headers: [HTTPHeader]
+ let headerCount: Int
+ let securityGrade: String?
+ let statusCode: Int?
+ let responseTimeMs: Int?
+ let httpProtocol: String?
+ let http3Advertised: Bool
+ let redirectChain: [RedirectHop]
+ let finalURL: String?
+ let tlsError: String?
+ let headersError: String?
+ let redirectError: String?
+}
+
+struct EmailSecuritySummary: Codable {
+ let records: EmailSecurityResult?
+ let summary: String
+ let error: String?
+}
+
+struct NetworkSummary: Codable {
+ let primaryIP: String?
+ let reachability: [PortReachability]
+ let reachabilitySummary: String
+ let reachabilityError: String?
+ let geolocation: IPGeolocation?
+ let geolocationSummary: String
+ let geolocationError: String?
+ let portScan: [PortScanResult]
+ let openPorts: [UInt16]
+ let portScanError: String?
+}
+
+struct DomainReportBuilder {
+ func build(from snapshot: LookupSnapshot, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
+ let primaryIP = primaryIPAddress(from: snapshot)
+
+ return DomainReport(
+ domain: snapshot.domain,
+ timestamp: snapshot.timestamp,
+ availability: snapshot.availabilityResult?.status ?? .unknown,
+ ownership: snapshot.ownership,
+ dns: DNSResultSummary(
+ resolverDisplayName: snapshot.resolverDisplayName,
+ resolverURLString: snapshot.resolverURLString,
+ lookupDurationMs: snapshot.totalLookupDurationMs,
+ recordSections: snapshot.dnsSections,
+ primaryIP: primaryIP,
+ ptrRecord: snapshot.ptrRecord,
+ dnssecSigned: dnssecSigned(from: snapshot),
+ error: snapshot.dnsError,
+ ptrError: snapshot.ptrError
+ ),
+ web: WebResultSummary(
+ tls: snapshot.sslInfo,
+ tlsStatus: tlsStatus(from: snapshot),
+ certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
+ hstsPreloaded: snapshot.hstsPreloaded,
+ headers: snapshot.httpHeaders,
+ headerCount: snapshot.httpHeaders.count,
+ securityGrade: snapshot.httpSecurityGrade,
+ statusCode: snapshot.httpStatusCode,
+ responseTimeMs: snapshot.httpResponseTimeMs,
+ httpProtocol: snapshot.httpProtocol,
+ http3Advertised: snapshot.http3Advertised,
+ redirectChain: snapshot.redirectChain,
+ finalURL: snapshot.redirectChain.last?.url,
+ tlsError: snapshot.sslError,
+ headersError: snapshot.httpHeadersError,
+ redirectError: snapshot.redirectChainError
+ ),
+ email: EmailSecuritySummary(
+ records: snapshot.emailSecurity,
+ summary: emailSummary(from: snapshot),
+ error: snapshot.emailSecurityError
+ ),
+ network: NetworkSummary(
+ primaryIP: primaryIP,
+ reachability: snapshot.reachabilityResults,
+ reachabilitySummary: reachabilitySummary(from: snapshot),
+ reachabilityError: snapshot.reachabilityError,
+ geolocation: snapshot.ipGeolocation,
+ geolocationSummary: geolocationSummary(from: snapshot),
+ geolocationError: snapshot.ipGeolocationError,
+ portScan: snapshot.portScanResults,
+ openPorts: snapshot.portScanResults.filter(\.open).map(\.port),
+ portScanError: snapshot.portScanError
+ ),
+ subdomains: snapshot.subdomains.map(\.hostname),
+ changeSummary: snapshot.changeSummary ?? previousSnapshot.map {
+ DomainDiffService.summary(from: $0, to: snapshot, generatedAt: snapshot.timestamp)
+ }
+ )
+ }
+
+ func build(from entry: HistoryEntry, previousSnapshot: LookupSnapshot? = nil) -> DomainReport {
+ build(from: entry.snapshot, previousSnapshot: previousSnapshot)
+ }
+
+ private func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
+ snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+ }
+
+ private func dnssecSigned(from snapshot: LookupSnapshot) -> Bool? {
+ snapshot.dnsSections.compactMap(\.dnssecSigned).first
+ }
+
+ private func tlsStatus(from snapshot: LookupSnapshot) -> String {
+ if snapshot.sslInfo != nil {
+ return "valid"
+ }
+ if let sslError = snapshot.sslError {
+ return sslError.localizedCaseInsensitiveContains("certificate") ? "invalid" : "failed"
+ }
+ return "unavailable"
+ }
+
+ private func emailSummary(from snapshot: LookupSnapshot) -> String {
+ guard let emailSecurity = snapshot.emailSecurity else {
+ return snapshot.emailSecurityError ?? "Unavailable"
+ }
+
+ return [
+ "SPF \(emailSecurity.spf.found ? "Yes" : "No")",
+ "DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")",
+ "DKIM \(emailSecurity.dkim.found ? "Yes" : "No")",
+ "BIMI \(emailSecurity.bimi.found ? "Yes" : "No")",
+ "MTA-STS \(emailSecurity.mtaSts?.txtFound == true ? "Yes" : "No")"
+ ].joined(separator: " / ")
+ }
+
+ private func reachabilitySummary(from snapshot: LookupSnapshot) -> String {
+ guard !snapshot.reachabilityResults.isEmpty else {
+ return snapshot.reachabilityError ?? "Unavailable"
+ }
+
+ return snapshot.reachabilityResults
+ .sorted { $0.port < $1.port }
+ .map { "\($0.port):\($0.reachable ? "open" : "closed")" }
+ .joined(separator: ", ")
+ }
+
+ private func geolocationSummary(from snapshot: LookupSnapshot) -> String {
+ guard let geolocation = snapshot.ipGeolocation else {
+ return snapshot.ipGeolocationError ?? "Unavailable"
+ }
+
+ let parts = [geolocation.city, geolocation.region, geolocation.country_name]
+ .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines) }
+ .filter { !$0.isEmpty }
+
+ if !parts.isEmpty {
+ return parts.joined(separator: ", ")
+ }
+
+ return geolocation.ip
+ }
+}
diff --git a/DomainReportExporter.swift b/DomainReportExporter.swift
new file mode 100644
index 0000000..19271fa
--- /dev/null
+++ b/DomainReportExporter.swift
@@ -0,0 +1,367 @@
+import Foundation
+
+enum DomainExportFormat: String {
+ case text = "txt"
+ case csv = "csv"
+ case json = "json"
+
+ var fileExtension: String { rawValue }
+}
+
+enum DomainReportExporter {
+ static func data(for report: DomainReport, format: DomainExportFormat) throws -> Data {
+ switch format {
+ case .text:
+ return Data(text(for: report).utf8)
+ case .csv:
+ return Data(csv(for: [report]).utf8)
+ case .json:
+ return try jsonEncoder.encode(report)
+ }
+ }
+
+ static func data(for reports: [DomainReport], format: DomainExportFormat, title: String) throws -> Data {
+ switch format {
+ case .text:
+ return Data(batchText(for: reports, title: title).utf8)
+ case .csv:
+ return Data(csv(for: reports).utf8)
+ case .json:
+ return try jsonEncoder.encode(reports)
+ }
+ }
+
+ static func text(for report: DomainReport) -> String {
+ var lines = [
+ "DomainDig Report",
+ "Domain: \(report.domain)",
+ "Timestamp: \(textDateFormatter.string(from: report.timestamp))",
+ "Availability: \(availabilityLabel(report.availability))"
+ ]
+
+ appendSection("Summary", to: &lines) {
+ [
+ "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
+ "TLS Status: \(report.web.tlsStatus)",
+ "HTTP: \(httpSummary(for: report))",
+ "Email: \(report.email.summary)",
+ "Subdomains: \(report.subdomains.count)"
+ ]
+ }
+
+ appendSection("Ownership", to: &lines) {
+ var ownershipLines = [
+ "Registrar: \(report.ownership?.registrar ?? "Unavailable")",
+ "Created: \(ownershipDateLabel(report.ownership?.createdDate))",
+ "Expires: \(ownershipDateLabel(report.ownership?.expirationDate))",
+ "Nameservers: \(joined(report.ownership?.nameservers) ?? "Unavailable")",
+ "Status: \(joined(report.ownership?.status) ?? "Unavailable")",
+ "Abuse Contact: \(report.ownership?.abuseEmail ?? "Unavailable")"
+ ]
+ if let error = report.dns.error, report.ownership == nil {
+ ownershipLines.append("Error: \(error)")
+ } else if let error = report.changeSummary?.message, report.ownership == nil, report.ownership == nil {
+ _ = error
+ }
+ return ownershipLines
+ }
+
+ appendSection("DNS", to: &lines) {
+ var dnsLines = [
+ "Resolver: \(report.dns.resolverDisplayName)",
+ "Resolver URL: \(report.dns.resolverURLString)",
+ "Lookup Duration: \(durationLabel(report.dns.lookupDurationMs))",
+ "Primary IP: \(report.dns.primaryIP ?? "Unavailable")",
+ "PTR: \(report.dns.ptrRecord ?? report.dns.ptrError ?? "Unavailable")",
+ "DNSSEC: \(dnssecLabel(report.dns.dnssecSigned))"
+ ]
+ if let error = report.dns.error {
+ dnsLines.append("Error: \(error)")
+ }
+ if report.dns.recordSections.isEmpty {
+ dnsLines.append("Records: None")
+ } else {
+ dnsLines.append("Records:")
+ for section in report.dns.recordSections {
+ let values = (section.records + section.wildcardRecords).map(\.value)
+ let renderedValues = values.isEmpty ? "None" : values.joined(separator: " | ")
+ dnsLines.append(" \(section.recordType.rawValue): \(renderedValues)")
+ }
+ }
+ return dnsLines
+ }
+
+ appendSection("Web", to: &lines) {
+ var webLines = [
+ "TLS Status: \(report.web.tlsStatus)",
+ "Certificate Warning: \(report.web.certificateWarningLevel.title)",
+ "Security Grade: \(report.web.securityGrade ?? "Unavailable")",
+ "HTTP Status: \(report.web.statusCode.map(String.init) ?? "Unavailable")",
+ "Protocol: \(report.web.httpProtocol ?? "Unavailable")",
+ "HTTP/3 Advertised: \(report.web.http3Advertised ? "Yes" : "No")",
+ "Final URL: \(report.web.finalURL ?? "Unavailable")",
+ "HSTS Preloaded: \(booleanLabel(report.web.hstsPreloaded))",
+ "Header Count: \(report.web.headerCount)"
+ ]
+ if let tlsError = report.web.tlsError {
+ webLines.append("TLS Error: \(tlsError)")
+ }
+ if let headersError = report.web.headersError {
+ webLines.append("Headers Error: \(headersError)")
+ }
+ if let redirectError = report.web.redirectError {
+ webLines.append("Redirect Error: \(redirectError)")
+ }
+ if !report.web.headers.isEmpty {
+ webLines.append("Headers:")
+ for header in report.web.headers {
+ webLines.append(" \(header.name): \(header.value)")
+ }
+ }
+ if !report.web.redirectChain.isEmpty {
+ webLines.append("Redirect Chain:")
+ for hop in report.web.redirectChain {
+ webLines.append(" \(hop.stepNumber). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")")
+ }
+ }
+ return webLines
+ }
+
+ appendSection("Email", to: &lines) {
+ var emailLines = [report.email.summary]
+ if let records = report.email.records {
+ emailLines.append("SPF: \(recordLabel(records.spf))")
+ emailLines.append("DMARC: \(recordLabel(records.dmarc))")
+ emailLines.append("DKIM: \(recordLabel(records.dkim))")
+ emailLines.append("BIMI: \(recordLabel(records.bimi))")
+ emailLines.append("MTA-STS: \(records.mtaSts?.txtFound == true ? records.mtaSts?.policyMode ?? "found" : "Unavailable")")
+ }
+ if let error = report.email.error {
+ emailLines.append("Error: \(error)")
+ }
+ return emailLines
+ }
+
+ appendSection("Network", to: &lines) {
+ var networkLines = [
+ "Reachability: \(report.network.reachabilitySummary)",
+ "Geolocation: \(report.network.geolocationSummary)",
+ "Open Ports: \(report.network.openPorts.map(String.init).joined(separator: ", ").nilIfEmpty ?? "None")"
+ ]
+ if let error = report.network.reachabilityError {
+ networkLines.append("Reachability Error: \(error)")
+ }
+ if let error = report.network.geolocationError {
+ networkLines.append("Geolocation Error: \(error)")
+ }
+ if let error = report.network.portScanError {
+ networkLines.append("Port Scan Error: \(error)")
+ }
+ if !report.network.portScan.isEmpty {
+ networkLines.append("Port Scan:")
+ for result in report.network.portScan {
+ networkLines.append(
+ " \(result.port) \(result.service): \(result.open ? "open" : "closed")\(result.banner.map { " banner=\($0)" } ?? "")"
+ )
+ }
+ }
+ return networkLines
+ }
+
+ appendSection("Subdomains", to: &lines) {
+ if report.subdomains.isEmpty {
+ return ["None"]
+ }
+ return report.subdomains.map { "- \($0)" }
+ }
+
+ appendSection("Changes", to: &lines) {
+ guard let changeSummary = report.changeSummary else {
+ return ["No comparison available"]
+ }
+
+ return [
+ "Has Changes: \(changeSummary.hasChanges ? "Yes" : "No")",
+ "Severity: \(changeSummary.severity.title)",
+ "Summary: \(changeSummary.message)",
+ "Changed Sections: \(changeSummary.changedSections.isEmpty ? "None" : changeSummary.changedSections.joined(separator: ", "))"
+ ]
+ }
+
+ return lines.joined(separator: "\n")
+ }
+
+ static func batchText(for reports: [DomainReport], title: String) -> String {
+ guard !reports.isEmpty else {
+ return "\(title)\nNo results available."
+ }
+
+ var lines = [title, String(repeating: "=", count: title.count), ""]
+ for (index, report) in reports.enumerated() {
+ if index > 0 {
+ lines.append("")
+ lines.append(String(repeating: "=", count: 48))
+ lines.append("")
+ }
+ lines.append(text(for: report))
+ }
+ return lines.joined(separator: "\n")
+ }
+
+ static func csv(for reports: [DomainReport]) -> String {
+ let headers = [
+ "domain",
+ "timestamp",
+ "availability",
+ "registrar",
+ "ownership_expires",
+ "nameservers",
+ "primary_ip",
+ "ptr_record",
+ "dnssec_signed",
+ "tls_status",
+ "certificate_warning_level",
+ "hsts_preloaded",
+ "http_status",
+ "http_security_grade",
+ "final_url",
+ "email_summary",
+ "subdomain_count",
+ "subdomains",
+ "open_ports",
+ "reachability_summary",
+ "geolocation_summary",
+ "change_summary"
+ ]
+
+ let rows = reports.map { report in
+ let expirationDate = report.ownership?.expirationDate.map(csvDateFormatter.string(from:)) ?? ""
+ let nameservers = joined(report.ownership?.nameservers) ?? ""
+ let dnssecSigned = report.dns.dnssecSigned.map { $0 ? "true" : "false" } ?? ""
+ let hstsPreloaded = report.web.hstsPreloaded.map { $0 ? "true" : "false" } ?? ""
+ let httpStatus = report.web.statusCode.map(String.init) ?? ""
+ let subdomainCount = String(report.subdomains.count)
+ let subdomains = report.subdomains.joined(separator: " | ")
+ let openPorts = report.network.openPorts.map(String.init).joined(separator: " | ")
+
+ return [
+ report.domain,
+ csvDateFormatter.string(from: report.timestamp),
+ availabilityLabel(report.availability),
+ report.ownership?.registrar ?? "",
+ expirationDate,
+ nameservers,
+ report.dns.primaryIP ?? "",
+ report.dns.ptrRecord ?? "",
+ dnssecSigned,
+ report.web.tlsStatus,
+ report.web.certificateWarningLevel.rawValue,
+ hstsPreloaded,
+ httpStatus,
+ report.web.securityGrade ?? "",
+ report.web.finalURL ?? "",
+ report.email.summary,
+ subdomainCount,
+ subdomains,
+ openPorts,
+ report.network.reachabilitySummary,
+ report.network.geolocationSummary,
+ report.changeSummary?.message ?? ""
+ ]
+ }
+
+ return ([headers] + rows)
+ .map { row in row.map(csvEscaped).joined(separator: ",") }
+ .joined(separator: "\n")
+ }
+
+ private static func appendSection(_ title: String, to lines: inout [String], body: () -> [String]) {
+ lines.append("")
+ lines.append(title)
+ lines.append(String(repeating: "-", count: title.count))
+ lines.append(contentsOf: body())
+ }
+
+ private static func availabilityLabel(_ status: DomainAvailabilityStatus) -> String {
+ switch status {
+ case .available:
+ return "Available"
+ case .registered:
+ return "Registered"
+ case .unknown:
+ return "Unknown"
+ }
+ }
+
+ private static func joined(_ values: [String]?) -> String? {
+ guard let values, !values.isEmpty else { return nil }
+ return values.joined(separator: " | ")
+ }
+
+ private static func dnssecLabel(_ value: Bool?) -> String {
+ switch value {
+ case true:
+ return "Signed"
+ case false:
+ return "Unsigned"
+ case nil:
+ return "Unavailable"
+ }
+ }
+
+ private static func ownershipDateLabel(_ date: Date?) -> String {
+ guard let date else { return "Unavailable" }
+ return textDateFormatter.string(from: date)
+ }
+
+ private static func durationLabel(_ durationMs: Int?) -> String {
+ durationMs.map { "\($0) ms" } ?? "Unavailable"
+ }
+
+ private static func httpSummary(for report: DomainReport) -> String {
+ let parts = [report.web.statusCode.map(String.init), report.web.securityGrade].compactMap { $0 }
+ return parts.isEmpty ? report.web.headersError ?? "Unavailable" : parts.joined(separator: " / ")
+ }
+
+ private static func booleanLabel(_ value: Bool?) -> String {
+ guard let value else { return "Unavailable" }
+ return value ? "Yes" : "No"
+ }
+
+ private static func recordLabel(_ record: EmailSecurityRecord) -> String {
+ if record.found {
+ return record.value ?? "Present"
+ }
+ return "Unavailable"
+ }
+
+ private static let textDateFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ return formatter
+ }()
+
+ private static let csvDateFormatter: ISO8601DateFormatter = {
+ let formatter = ISO8601DateFormatter()
+ formatter.formatOptions = [.withInternetDateTime]
+ return formatter
+ }()
+
+ private static let jsonEncoder: JSONEncoder = {
+ let encoder = JSONEncoder()
+ encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
+ encoder.dateEncodingStrategy = .iso8601
+ return encoder
+ }()
+
+ nonisolated private static func csvEscaped(_ value: String) -> String {
+ let escaped = value.replacingOccurrences(of: "\"", with: "\"\"")
+ return "\"\(escaped)\""
+ }
+}
+
+private extension String {
+ var nilIfEmpty: String? {
+ isEmpty ? nil : self
+ }
+}
diff --git a/LookupSnapshot.swift b/LookupSnapshot.swift
new file mode 100644
index 0000000..3a35eff
--- /dev/null
+++ b/LookupSnapshot.swift
@@ -0,0 +1,89 @@
+import Foundation
+
+struct LookupSnapshot {
+ let historyEntryID: UUID?
+ let domain: String
+ let timestamp: Date
+ let trackedDomainID: UUID?
+ let resolverDisplayName: String
+ let resolverURLString: String
+ let totalLookupDurationMs: Int?
+ let dnsSections: [DNSSection]
+ let dnsError: String?
+ let availabilityResult: DomainAvailabilityResult?
+ let suggestions: [DomainSuggestionResult]
+ let sslInfo: SSLCertificateInfo?
+ let sslError: String?
+ let hstsPreloaded: Bool?
+ let httpHeaders: [HTTPHeader]
+ let httpSecurityGrade: String?
+ let httpStatusCode: Int?
+ let httpResponseTimeMs: Int?
+ let httpProtocol: String?
+ let http3Advertised: Bool
+ let httpHeadersError: String?
+ let reachabilityResults: [PortReachability]
+ let reachabilityError: String?
+ let ipGeolocation: IPGeolocation?
+ let ipGeolocationError: String?
+ let emailSecurity: EmailSecurityResult?
+ let emailSecurityError: String?
+ let ownership: DomainOwnership?
+ let ownershipError: String?
+ let ptrRecord: String?
+ let ptrError: String?
+ let redirectChain: [RedirectHop]
+ let redirectChainError: String?
+ let subdomains: [DiscoveredSubdomain]
+ let subdomainsError: String?
+ let portScanResults: [PortScanResult]
+ let portScanError: String?
+ let changeSummary: DomainChangeSummary?
+ let isLive: Bool
+}
+
+extension HistoryEntry {
+ var snapshot: LookupSnapshot {
+ LookupSnapshot(
+ historyEntryID: id,
+ domain: domain,
+ timestamp: timestamp,
+ trackedDomainID: trackedDomainID,
+ resolverDisplayName: resolverDisplayName,
+ resolverURLString: resolverURLString,
+ totalLookupDurationMs: totalLookupDurationMs,
+ dnsSections: dnsSections,
+ dnsError: nil,
+ availabilityResult: availabilityResult,
+ suggestions: suggestions,
+ sslInfo: sslInfo,
+ sslError: sslError,
+ hstsPreloaded: hstsPreloaded,
+ httpHeaders: httpHeaders,
+ httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue,
+ httpStatusCode: nil,
+ httpResponseTimeMs: nil,
+ httpProtocol: nil,
+ http3Advertised: false,
+ httpHeadersError: httpHeadersError,
+ reachabilityResults: reachabilityResults,
+ reachabilityError: reachabilityError,
+ ipGeolocation: ipGeolocation,
+ ipGeolocationError: ipGeolocationError,
+ emailSecurity: emailSecurity,
+ emailSecurityError: emailSecurityError,
+ ownership: ownership,
+ ownershipError: ownershipError,
+ ptrRecord: ptrRecord,
+ ptrError: ptrError,
+ redirectChain: redirectChain,
+ redirectChainError: redirectChainError,
+ subdomains: subdomains,
+ subdomainsError: subdomainsError,
+ portScanResults: portScanResults,
+ portScanError: portScanError,
+ changeSummary: changeSummary,
+ isLive: false
+ )
+ }
+}