diff options
| author | Christian Cleberg <[email protected]> | 2026-04-21 21:53:16 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-04-21 21:53:16 -0500 |
| commit | 1f58092dcb67cded75a850db364f3b46d86181f3 (patch) | |
| tree | 662731251ed1022b4a1585ec74379f233ca6705a /DomainDig/DomainDiffService.swift | |
| parent | 2e90ee4ceca73d5126118920b486149cb227c4e9 (diff) | |
| download | domain-dig-1f58092dcb67cded75a850db364f3b46d86181f3.tar.gz domain-dig-1f58092dcb67cded75a850db364f3b46d86181f3.tar.bz2 domain-dig-1f58092dcb67cded75a850db364f3b46d86181f3.zip | |
feat(v2.2.0): add foreground monitoring, notifications, and smarter diffs
- implement “Check All” sweep for tracked domains
- add local notifications for changes and certificate expiration
- introduce change severity filtering (low/medium/high)
- enhance change summaries and diff readability
- add certificate expiration tracking and alerts
- improve watchlist indicators for changes
- add sweep summary screen
- optimize performance for larger watchlists
Diffstat (limited to 'DomainDig/DomainDiffService.swift')
| -rw-r--r-- | DomainDig/DomainDiffService.swift | 337 |
1 files changed, 276 insertions, 61 deletions
diff --git a/DomainDig/DomainDiffService.swift b/DomainDig/DomainDiffService.swift index 65ea5a7..667802a 100644 --- a/DomainDig/DomainDiffService.swift +++ b/DomainDig/DomainDiffService.swift @@ -13,10 +13,15 @@ struct DomainDiffItem: Identifiable, Equatable { let changeType: DiffChangeType let oldValue: String? let newValue: String? + let severity: ChangeSeverity var hasChanges: Bool { changeType != .unchanged } + + var isMeaningful: Bool { + hasChanges && severity >= .medium + } } struct DomainDiffSection: Identifiable, Equatable { @@ -27,27 +32,19 @@ struct DomainDiffSection: Identifiable, Equatable { var hasChanges: Bool { items.contains(where: \.hasChanges) } + + var severity: ChangeSeverity { + items.map(\.severity).max() ?? .low + } } enum DomainDiffService { static func diff(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> [DomainDiffSection] { [ - section(title: "Availability", item: compare( - label: "Status", - oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), - newValue: availabilityLabel(newSnapshot.availabilityResult?.status) - )), - section(title: "Primary IP", item: compare( - label: "Address", - oldValue: primaryIP(from: oldSnapshot), - newValue: primaryIP(from: newSnapshot) - )), + availabilitySection(from: oldSnapshot, to: newSnapshot), + primaryIPSection(from: oldSnapshot, to: newSnapshot), dnsSection(from: oldSnapshot, to: newSnapshot), - section(title: "Redirect", item: compare( - label: "Final Target", - oldValue: finalRedirectURL(from: oldSnapshot), - newValue: finalRedirectURL(from: newSnapshot) - )), + redirectSection(from: oldSnapshot, to: newSnapshot), tlsSection(from: oldSnapshot, to: newSnapshot), httpSection(from: oldSnapshot, to: newSnapshot), emailSection(from: oldSnapshot, to: newSnapshot) @@ -55,62 +52,212 @@ enum DomainDiffService { .filter { !$0.items.isEmpty } } - static func summary(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot, generatedAt: Date = Date()) -> DomainChangeSummary { - let changedSections = diff(from: oldSnapshot, to: newSnapshot) - .filter { $0.items.contains(where: { $0.changeType != .unchanged }) } - .map(\.title) + static func summary( + from oldSnapshot: LookupSnapshot, + to newSnapshot: LookupSnapshot, + generatedAt: Date = Date() + ) -> DomainChangeSummary { + let sections = diff(from: oldSnapshot, to: newSnapshot) + let meaningfulItems = sections + .flatMap(\.items) + .filter(\.isMeaningful) + let allChangedItems = sections + .flatMap(\.items) + .filter(\.hasChanges) + + let highlights = summaryHighlights(from: meaningfulItems) + let severity = meaningfulItems.map(\.severity).max() ?? (allChangedItems.isEmpty ? .low : .low) + let message = summaryMessage(from: meaningfulItems, highlights: highlights) return DomainChangeSummary( - hasChanges: !changedSections.isEmpty, - changedSections: changedSections, + hasChanges: !meaningfulItems.isEmpty, + changedSections: highlights, + message: message, + severity: severity, generatedAt: generatedAt ) } - private static func section(title: String, item: DomainDiffItem?) -> DomainDiffSection { - DomainDiffSection(title: title, items: item.map { [$0] } ?? []) + static func certificateWarningLevel(for snapshot: LookupSnapshot) -> CertificateWarningLevel { + guard let days = snapshot.sslInfo?.daysUntilExpiry else { + return .none + } + if days < 14 { + return .critical + } + if days < 30 { + return .warning + } + return .none + } + + private static func availabilitySection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + DomainDiffSection( + title: "Availability", + items: [ + compare( + label: "Availability", + oldValue: availabilityLabel(oldSnapshot.availabilityResult?.status), + newValue: availabilityLabel(newSnapshot.availabilityResult?.status), + severity: .high + ) + ].compactMap { $0 } + ) + } + + private static func primaryIPSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + DomainDiffSection( + title: "Primary IP", + items: [ + compare( + label: "Primary IP", + oldValue: primaryIP(from: oldSnapshot), + newValue: primaryIP(from: newSnapshot), + severity: .high + ) + ].compactMap { $0 } + ) } private static func dnsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - let oldValue = normalizedDNSSummary(from: oldSnapshot) - let newValue = normalizedDNSSummary(from: newSnapshot) - return section(title: "DNS Records", item: compare(label: "Records", oldValue: oldValue, newValue: newValue)) + let oldSections = Dictionary(uniqueKeysWithValues: oldSnapshot.dnsSections.map { ($0.recordType, $0) }) + let newSections = Dictionary(uniqueKeysWithValues: newSnapshot.dnsSections.map { ($0.recordType, $0) }) + let types = Set(oldSections.keys).union(newSections.keys).sorted { $0.rawValue < $1.rawValue } + + var items: [DomainDiffItem] = [] + for type in types { + let oldSection = oldSections[type] + let newSection = newSections[type] + + if let recordChange = compare( + label: "\(type.rawValue) Records", + oldValue: normalizedRecordValues(for: oldSection), + newValue: normalizedRecordValues(for: newSection), + severity: .medium + ) { + items.append(recordChange) + } + + if let ttlChange = compare( + label: "\(type.rawValue) TTL", + oldValue: normalizedTTLValues(for: oldSection), + newValue: normalizedTTLValues(for: newSection), + severity: .low + ), let oldSection, let newSection, + normalizedRecordValues(for: oldSection) == normalizedRecordValues(for: newSection) { + items.append(ttlChange) + } + } + + return DomainDiffSection(title: "DNS", items: items) + } + + private static func redirectSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { + DomainDiffSection( + title: "Redirect", + items: [ + compare( + label: "Redirect Target", + oldValue: finalRedirectURL(from: oldSnapshot), + newValue: finalRedirectURL(from: newSnapshot), + severity: .high + ) + ].compactMap { $0 } + ) } private static func tlsSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { var items: [DomainDiffItem] = [] - if let item = compare(label: "Issuer", oldValue: normalized(oldSnapshot.sslInfo?.issuer), newValue: normalized(newSnapshot.sslInfo?.issuer)) { - items.append(item) + + if let issuerChange = compare( + label: "TLS Issuer", + oldValue: normalized(oldSnapshot.sslInfo?.issuer), + newValue: normalized(newSnapshot.sslInfo?.issuer), + severity: .medium + ) { + items.append(issuerChange) + } + + if let expiryChange = compare( + label: "TLS Expiration", + oldValue: expirationLabel(oldSnapshot.sslInfo), + newValue: expirationLabel(newSnapshot.sslInfo), + severity: .medium + ) { + items.append(expiryChange) } - if let item = compare(label: "Certificate", oldValue: tlsSummary(from: oldSnapshot), newValue: tlsSummary(from: newSnapshot)) { - items.append(item) + + let oldWarning = certificateWarningLevel(for: oldSnapshot) + let newWarning = certificateWarningLevel(for: newSnapshot) + if oldWarning != newWarning, newWarning != .none { + let days = newSnapshot.sslInfo?.daysUntilExpiry ?? 0 + items.append( + DomainDiffItem( + label: "Certificate Warning", + changeType: .changed, + oldValue: oldWarning.title, + newValue: "Certificate expires in \(days) days", + severity: newWarning == .critical ? .high : .medium + ) + ) } - return DomainDiffSection(title: "TLS Certificate", items: items) + + return DomainDiffSection(title: "TLS", items: items) } private static func httpSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { var items: [DomainDiffItem] = [] - if let item = compare(label: "HTTP Status", oldValue: httpStatusSummary(from: oldSnapshot), newValue: httpStatusSummary(from: newSnapshot)) { - items.append(item) + + if let statusChange = compare( + label: "HTTP Status", + oldValue: httpStatusSummary(from: oldSnapshot), + newValue: httpStatusSummary(from: newSnapshot), + severity: .medium + ) { + items.append(statusChange) } - if let item = compare(label: "Security Grade", oldValue: normalized(oldSnapshot.httpSecurityGrade), newValue: normalized(newSnapshot.httpSecurityGrade)) { - items.append(item) + + if let gradeChange = compare( + label: "Security Grade", + oldValue: normalized(oldSnapshot.httpSecurityGrade), + newValue: normalized(newSnapshot.httpSecurityGrade), + severity: .low + ) { + items.append(gradeChange) + } + + if let headerChange = compare( + label: "Headers", + oldValue: normalizedHeaders(from: oldSnapshot), + newValue: normalizedHeaders(from: newSnapshot), + severity: .low + ) { + items.append(headerChange) } + return DomainDiffSection(title: "HTTP", items: items) } private static func emailSection(from oldSnapshot: LookupSnapshot, to newSnapshot: LookupSnapshot) -> DomainDiffSection { - section( + DomainDiffSection( title: "Email Security", - item: compare( - label: "Summary", - oldValue: normalized(emailSummary(from: oldSnapshot)), - newValue: normalized(emailSummary(from: newSnapshot)) - ) + items: [ + compare( + label: "Email Security", + oldValue: normalized(emailSummary(from: oldSnapshot)), + newValue: normalized(emailSummary(from: newSnapshot)), + severity: .medium + ) + ].compactMap { $0 } ) } - private static func compare(label: String, oldValue: String?, newValue: String?) -> DomainDiffItem? { + private static func compare( + label: String, + oldValue: String?, + newValue: String?, + severity: ChangeSeverity + ) -> DomainDiffItem? { let oldValue = normalized(oldValue) let newValue = normalized(newValue) let normalizedOldValue = comparisonValue(oldValue) @@ -132,7 +279,68 @@ enum DomainDiffService { changeType = .changed } - return DomainDiffItem(label: label, changeType: changeType, oldValue: oldValue, newValue: newValue) + return DomainDiffItem( + label: label, + changeType: changeType, + oldValue: oldValue, + newValue: newValue, + severity: severity + ) + } + + private static func summaryHighlights(from items: [DomainDiffItem]) -> [String] { + var highlights: [String] = [] + + let labels = Set(items.map(\.label)) + if labels.contains("Availability") { + highlights.append("Availability changed") + } + if labels.contains("Primary IP"), labels.contains(where: { $0.hasSuffix("Records") }) { + highlights.append("IP changed") + highlights.append("DNS changed") + return highlights + } + if labels.contains("Primary IP") { + highlights.append("IP changed") + } + if labels.contains("Redirect Target") { + highlights.append("Redirect target changed") + } + if let certificateItem = items.first(where: { $0.label == "Certificate Warning" }), + let message = certificateItem.newValue { + highlights.append(message) + } else if labels.contains("TLS Issuer") { + highlights.append("TLS issuer changed") + } else if labels.contains("TLS Expiration") { + highlights.append("Certificate expiration changed") + } + if labels.contains(where: { $0.hasSuffix("Records") }) { + highlights.append("DNS changed") + } + if labels.contains("HTTP Status") { + highlights.append("HTTP status changed") + } + if labels.contains("Email Security") { + highlights.append("Email security changed") + } + + var deduplicated: [String] = [] + for highlight in highlights where !deduplicated.contains(highlight) { + deduplicated.append(highlight) + } + return deduplicated + } + + private static func summaryMessage(from items: [DomainDiffItem], highlights: [String]) -> String { + guard !items.isEmpty, !highlights.isEmpty else { + return "No meaningful changes" + } + + if highlights.count == 1 { + return highlights[0] + } + + return "\(highlights[0]) and \(highlights[1].lowercased())" } private static func normalized(_ value: String?) -> String? { @@ -167,11 +375,9 @@ enum DomainDiffService { snapshot.redirectChain.last?.url } - private static func tlsSummary(from snapshot: LookupSnapshot) -> String? { - if let sslInfo = snapshot.sslInfo { - return "\(sslInfo.commonName) | \(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted))" - } - return snapshot.sslError + private static func expirationLabel(_ sslInfo: SSLCertificateInfo?) -> String? { + guard let sslInfo else { return nil } + return "\(sslInfo.validUntil.formatted(date: .abbreviated, time: .omitted)) (\(sslInfo.daysUntilExpiry)d)" } private static func httpStatusSummary(from snapshot: LookupSnapshot) -> String? { @@ -194,18 +400,27 @@ enum DomainDiffService { return snapshot.emailSecurityError } - private static func normalizedDNSSummary(from snapshot: LookupSnapshot) -> String? { - let parts = snapshot.dnsSections - .sorted { $0.recordType.rawValue < $1.recordType.rawValue } - .map { section in - let values = (section.records + section.wildcardRecords) - .map(\.value) - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } - .sorted() - .joined(separator: ",") - return "\(section.recordType.rawValue):\(values)" - } - .filter { !$0.hasSuffix(":") } - return parts.isEmpty ? nil : parts.joined(separator: "|") + private static func normalizedRecordValues(for section: DNSSection?) -> String? { + guard let section else { return nil } + let values = (section.records + section.wildcardRecords) + .map(\.value) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } + .sorted() + return values.isEmpty ? nil : values.joined(separator: ",") + } + + private static func normalizedTTLValues(for section: DNSSection?) -> String? { + guard let section else { return nil } + let values = (section.records + section.wildcardRecords) + .map { "\($0.value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()):\($0.ttl)" } + .sorted() + return values.isEmpty ? nil : values.joined(separator: ",") + } + + private static func normalizedHeaders(from snapshot: LookupSnapshot) -> String? { + let headers = snapshot.httpHeaders + .map { "\($0.name.lowercased()):\($0.value.trimmingCharacters(in: .whitespacesAndNewlines))" } + .sorted() + return headers.isEmpty ? nil : headers.joined(separator: "|") } } |
