krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
main: DomainDig/DomainMonitoringService.swift · raw
1import Foundation
2import UserNotifications
3import CryptoKit
4
5#if canImport(BackgroundTasks)
6import BackgroundTasks
7#endif
8
9#if canImport(UIKit)
10import UIKit
11#endif
12
13enum MonitoringStorage {
14 static let settingsKey = "monitoring.settings"
15 static let logsKey = "monitoring.logs"
16 static let trackedDomainsKey = "trackedDomains"
17 static let historyKey = "lookupHistory"
18 static let maxLogs = 40
19
20 static func loadSettings() -> MonitoringSettings {
21 DataMigrationService.migrateIfNeeded()
22 return DomainDataPortabilityService.loadMonitoringSettings()
23 }
24
25 static func saveSettings(_ settings: MonitoringSettings) {
26 DomainDataPortabilityService.saveMonitoringSettings(settings)
27 }
28
29 static func loadLogs() -> [MonitoringLog] {
30 DataMigrationService.migrateIfNeeded()
31 return DomainDataPortabilityService.loadMonitoringLogs()
32 }
33
34 static func saveLogs(_ logs: [MonitoringLog]) {
35 DomainDataPortabilityService.saveMonitoringLogs(Array(logs.prefix(maxLogs)))
36 }
37
38 static func loadTrackedDomains() -> [TrackedDomain] {
39 DataMigrationService.migrateIfNeeded()
40 return DomainDataPortabilityService.loadTrackedDomains()
41 }
42
43 static func saveTrackedDomains(_ domains: [TrackedDomain]) {
44 DomainDataPortabilityService.saveTrackedDomains(domains)
45 }
46
47 static func loadHistoryEntries() -> [HistoryEntry] {
48 DataMigrationService.migrateIfNeeded()
49 return DomainDataPortabilityService.loadHistoryEntries()
50 }
51
52 static func saveHistoryEntries(_ entries: [HistoryEntry]) {
53 DomainDataPortabilityService.saveHistoryEntries(entries)
54 }
55
56 static func sanitizeSettings(_ settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> MonitoringSettings {
57 let validIDs = Set(trackedDomains.map(\.id))
58 var sanitized = settings
59 sanitized.selectedDomainIDs = sanitized.selectedDomainIDs.filter { validIDs.contains($0) }
60 return sanitized
61 }
62
63 static func monitoredDomains(settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
64 let sanitized = sanitizeSettings(settings, trackedDomains: trackedDomains)
65 switch sanitized.scope {
66 case .allTracked:
67 return trackedDomains.filter(\.monitoringEnabled)
68 case .selectedOnly:
69 let selected = Set(sanitized.selectedDomainIDs)
70 return trackedDomains.filter { selected.contains($0.id) && $0.monitoringEnabled }
71 }
72 }
73}
74
75struct MonitoringRunOutcome {
76 let success: Bool
77 let message: String
78 let log: MonitoringLog?
79}
80
81@MainActor
82final class DomainMonitoringScheduler {
83 static let shared = DomainMonitoringScheduler()
84 static let taskIdentifier = "net.cleberg.DomainDig.monitor.refresh"
85
86 private var isRegistered = false
87
88 private init() { /* Singleton; use the shared instance. */ }
89
90 func registerBackgroundTask() {
91 #if canImport(BackgroundTasks)
92 guard !isRegistered else { return }
93 isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in
94 guard let refreshTask = task as? BGAppRefreshTask else {
95 task.setTaskCompleted(success: false)
96 return
97 }
98 self.handleAppRefresh(task: refreshTask)
99 }
100 #endif
101 }
102
103 func backgroundRefreshStatusDescription() -> String {
104 #if canImport(UIKit)
105 switch UIApplication.shared.backgroundRefreshStatus {
106 case .available:
107 return "Available"
108 case .denied:
109 return "Disabled in Settings"
110 case .restricted:
111 return "Restricted by the system"
112 @unknown default:
113 return "Unknown"
114 }
115 #else
116 return "Unavailable on this platform"
117 #endif
118 }
119
120 @discardableResult
121 func syncSchedule() -> String? {
122 #if canImport(BackgroundTasks)
123 let settings = MonitoringStorage.loadSettings()
124 guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
125 BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
126 return nil
127 }
128
129 #if canImport(UIKit)
130 guard UIApplication.shared.backgroundRefreshStatus == .available else {
131 BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
132 return "Background refresh is unavailable."
133 }
134 #endif
135
136 let trackedDomains = MonitoringStorage.loadTrackedDomains()
137 let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
138 request.earliestBeginDate = Date(
139 timeIntervalSinceNow: max(Self.nextScheduleInterval(settings: settings, trackedDomains: trackedDomains), 15 * 60)
140 )
141
142 do {
143 BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
144 try BGTaskScheduler.shared.submit(request)
145 return nil
146 } catch {
147 return "Could not schedule monitoring."
148 }
149 #else
150 return "Background monitoring is unavailable on this platform."
151 #endif
152 }
153
154 private static func nextScheduleInterval(
155 settings: MonitoringSettings,
156 trackedDomains: [TrackedDomain],
157 now: Date = Date()
158 ) -> TimeInterval {
159 let monitored = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: trackedDomains)
160 guard !monitored.isEmpty else {
161 return settings.config.sanitizedBaseInterval
162 }
163
164 return monitored
165 .map { trackedDomain in
166 let state = normalizedMonitoringState(for: trackedDomain, settings: settings)
167 guard let lastCheck = trackedDomain.monitoringState.lastCheck else {
168 return 0
169 }
170 return max(0, lastCheck.addingTimeInterval(state.currentInterval).timeIntervalSince(now))
171 }
172 .min() ?? settings.config.sanitizedBaseInterval
173 }
174
175 static func normalizedMonitoringState(
176 for trackedDomain: TrackedDomain,
177 settings: MonitoringSettings
178 ) -> MonitoringState {
179 var state = trackedDomain.monitoringState
180 if state.currentInterval <= 0 {
181 state.currentInterval = settings.config.sanitizedBaseInterval
182 }
183 return state
184 }
185
186#if canImport(BackgroundTasks)
187 private func handleAppRefresh(task: BGAppRefreshTask) {
188 _ = syncSchedule()
189
190 let worker = Task {
191 let outcome = await DomainMonitoringService.shared.performMonitoring(
192 trigger: .background,
193 requireEnabledSetting: true
194 )
195 task.setTaskCompleted(success: outcome.success)
196 }
197
198 task.expirationHandler = {
199 worker.cancel()
200 }
201 }
202#endif
203}
204
205@MainActor
206final class DomainMonitoringService {
207 static let shared = DomainMonitoringService()
208
209 private let inspectionService = DomainInspectionService()
210 private let maxHistoryEntries = 250
211 private let calendar = Calendar.current
212
213 func performMonitoring(
214 trigger: MonitoringRunTrigger,
215 requireEnabledSetting: Bool
216 ) async -> MonitoringRunOutcome {
217 let trackedDomains = MonitoringStorage.loadTrackedDomains()
218 var settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains)
219 MonitoringStorage.saveSettings(settings)
220
221 guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
222 if settings.isEnabled {
223 settings.isEnabled = false
224 MonitoringStorage.saveSettings(settings)
225 await MainActor.run {
226 _ = DomainMonitoringScheduler.shared.syncSchedule()
227 }
228 }
229 return MonitoringRunOutcome(success: false, message: "Monitoring requires Pro.", log: nil)
230 }
231
232 if requireEnabledSetting, !settings.isEnabled {
233 return MonitoringRunOutcome(success: false, message: "Monitoring is disabled.", log: nil)
234 }
235
236 var history = MonitoringStorage.loadHistoryEntries()
237 var mutableTrackedDomains = trackedDomains
238 let monitoredDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains)
239
240 guard !monitoredDomains.isEmpty else {
241 let log = MonitoringLog(
242 timestamp: Date(),
243 trigger: trigger,
244 domainsChecked: 0,
245 changesFound: 0,
246 alertsTriggered: 0,
247 checkedDomains: [],
248 errors: ["No tracked domains are configured for monitoring."]
249 )
250 saveLog(log)
251 return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log)
252 }
253
254 let eligibleDomains = monitoredDomains.filter { trackedDomain in
255 shouldInspect(trackedDomain: trackedDomain, trigger: trigger, settings: settings)
256 }
257
258 guard !eligibleDomains.isEmpty else {
259 let log = MonitoringLog(
260 timestamp: Date(),
261 trigger: trigger,
262 domainsChecked: 0,
263 changesFound: 0,
264 alertsTriggered: 0,
265 checkedDomains: [],
266 errors: []
267 )
268 saveLog(log)
269 return MonitoringRunOutcome(success: true, message: "No domains are due for monitoring.", log: log)
270 }
271
272 let notificationsAuthorized: Bool
273 if settings.alertsEnabled {
274 notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts()
275 } else {
276 notificationsAuthorized = false
277 }
278 var results: [MonitoringDomainResult] = []
279 var errors: [String] = []
280 var alertsTriggered = 0
281 let now = Date()
282
283 for trackedDomain in eligibleDomains {
284 guard !Task.isCancelled else {
285 return MonitoringRunOutcome(success: false, message: "Monitoring cancelled.", log: nil)
286 }
287
288 let previousSnapshot = latestSnapshot(for: trackedDomain, history: history)
289 let inspectedSnapshot = await inspectionService.inspectSnapshot(
290 domain: trackedDomain.domain,
291 previousSnapshot: previousSnapshot
292 )
293 let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot)
294 // When the fallback fires, `snapshot` *is* `previousSnapshot`, so
295 // every comparison below is old-against-old and would otherwise
296 // report "No meaningful changes" for a domain we never reached.
297 let unreachableReason = (previousSnapshot != nil && Self.shouldFallbackToSnapshot(inspectedSnapshot))
298 ? Self.firstErrorMessage(in: inspectedSnapshot)
299 : nil
300 let savedEntry: HistoryEntry?
301 if snapshot.statusMessage == nil {
302 savedEntry = persistSnapshot(
303 snapshot,
304 trackedDomainID: trackedDomain.id,
305 trackedDomains: &mutableTrackedDomains,
306 history: &history
307 )
308 } else {
309 savedEntry = history.first(where: { $0.id == snapshot.historyEntryID })
310 }
311
312 let alertDescriptor = alertDescriptor(
313 previousSnapshot: previousSnapshot,
314 snapshot: snapshot,
315 entry: savedEntry,
316 sensitivity: settings.sensitivity
317 )
318
319 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
320 mutableTrackedDomains[index].lastMonitoredAt = now
321 let outcome = await processAdaptiveMonitoringState(
322 trackedDomain: mutableTrackedDomains[index],
323 snapshot: snapshot,
324 previousSnapshot: previousSnapshot,
325 alertDescriptor: alertDescriptor,
326 settings: settings,
327 notificationsAuthorized: notificationsAuthorized,
328 now: now
329 )
330 mutableTrackedDomains[index] = outcome.trackedDomain
331 alertsTriggered += outcome.alertsTriggered
332 }
333
334 let result = MonitoringDomainResult(
335 domain: trackedDomain.domain,
336 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
337 checkedAt: now,
338 didChange: alertDescriptor != nil,
339 summaryMessage: unreachableReason.map { "Could not check — kept the previous result. \($0)" }
340 ?? snapshot.statusMessage
341 ?? alertDescriptor?.message
342 ?? savedEntry?.changeSummary?.message
343 ?? "No meaningful changes",
344 alertSeverity: alertDescriptor?.severity,
345 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
346 resultSource: snapshot.resultSource,
347 errorMessage: snapshot.statusMessage,
348 unreachableReason: unreachableReason
349 )
350 results.append(result)
351
352 if let errorMessage = result.errorMessage {
353 errors.append("\(trackedDomain.domain): \(errorMessage)")
354 }
355 }
356
357 MonitoringStorage.saveTrackedDomains(mutableTrackedDomains)
358 MonitoringStorage.saveHistoryEntries(history)
359
360 let log = MonitoringLog(
361 timestamp: Date(),
362 trigger: trigger,
363 domainsChecked: results.count,
364 changesFound: results.filter(\.didChange).count,
365 alertsTriggered: alertsTriggered,
366 checkedDomains: results.sorted {
367 $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
368 },
369 errors: errors
370 )
371 saveLog(log)
372 let outboundEvents = monitoringEvents(from: log)
373 if outboundEvents.isEmpty {
374 IntegrationService.shared.recordNoOutboundEvents(for: log.summary)
375 } else {
376 IntegrationService.shared.enqueue(events: outboundEvents)
377 }
378
379 return MonitoringRunOutcome(
380 success: errors.count < results.count,
381 message: log.summary,
382 log: log
383 )
384 }
385
386 private func saveLog(_ log: MonitoringLog) {
387 var logs = MonitoringStorage.loadLogs()
388 logs.insert(log, at: 0)
389 MonitoringStorage.saveLogs(logs)
390 }
391
392 private func monitoringEvents(from log: MonitoringLog) -> [MonitoringEvent] {
393 var events: [MonitoringEvent] = log.checkedDomains.compactMap { result in
394 if let errorMessage = result.errorMessage {
395 return MonitoringEvent(
396 type: .monitoringFailure,
397 severity: .critical,
398 domain: result.domain,
399 timestamp: result.checkedAt,
400 summary: errorMessage,
401 details: [
402 "trigger": log.trigger.rawValue,
403 "resultSource": result.resultSource.rawValue
404 ]
405 )
406 }
407
408 if let unreachableReason = result.unreachableReason {
409 return MonitoringEvent(
410 type: .monitoringFailure,
411 severity: .warning,
412 domain: result.domain,
413 timestamp: result.checkedAt,
414 summary: result.summaryMessage,
415 details: [
416 "reason": unreachableReason,
417 "trigger": log.trigger.rawValue,
418 "resultSource": result.resultSource.rawValue
419 ]
420 )
421 }
422
423 if result.certificateWarningLevel == .critical {
424 return MonitoringEvent(
425 type: .certificateExpiring,
426 severity: .critical,
427 domain: result.domain,
428 timestamp: result.checkedAt,
429 summary: result.summaryMessage,
430 details: [
431 "certificateWarningLevel": result.certificateWarningLevel.rawValue,
432 "trigger": log.trigger.rawValue
433 ]
434 )
435 }
436
437 guard let alertSeverity = result.alertSeverity else {
438 return nil
439 }
440
441 return MonitoringEvent(
442 type: eventType(for: result.summaryMessage),
443 severity: EventSeverity(monitoringSeverity: alertSeverity),
444 domain: result.domain,
445 timestamp: result.checkedAt,
446 summary: result.summaryMessage,
447 details: [
448 "alertSeverity": alertSeverity.title,
449 "certificateWarningLevel": result.certificateWarningLevel.rawValue,
450 "resultSource": result.resultSource.rawValue,
451 "trigger": log.trigger.rawValue
452 ]
453 )
454 }
455
456 if !log.errors.isEmpty {
457 events.append(
458 MonitoringEvent(
459 type: .monitoringFailure,
460 severity: .critical,
461 domain: "portfolio",
462 timestamp: log.timestamp,
463 summary: "Monitoring run completed with errors",
464 details: [
465 "errors": log.errors.joined(separator: " | "),
466 "trigger": log.trigger.rawValue
467 ]
468 )
469 )
470 }
471
472 return events
473 }
474
475 private func eventType(for summary: String) -> MonitoringEventType {
476 let normalized = summary.lowercased()
477 if normalized.contains("dns") {
478 return .dnsChanged
479 }
480 if normalized.contains("certificate") {
481 return .certificateUpdated
482 }
483 if normalized.contains("redirect") {
484 return .redirectChanged
485 }
486 if normalized.contains("header") {
487 return .headersChanged
488 }
489 return .changeDetected
490 }
491
492 private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? {
493 history.first(where: { entry in
494 if let trackedDomainID = entry.trackedDomainID {
495 return trackedDomainID == trackedDomain.id
496 }
497 return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
498 })?.snapshot
499 }
500
501 private func persistSnapshot(
502 _ snapshot: LookupSnapshot,
503 trackedDomainID: UUID,
504 trackedDomains: inout [TrackedDomain],
505 history: inout [HistoryEntry]
506 ) -> HistoryEntry? {
507 let previousSnapshot = latestSnapshot(
508 for: trackedDomains.first(where: { $0.id == trackedDomainID }) ?? TrackedDomain(domain: snapshot.domain),
509 history: history
510 )
511 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
512 let changeSummary = previousSnapshot.map {
513 DomainDiffService.summary(
514 from: $0,
515 to: snapshot,
516 generatedAt: snapshot.timestamp,
517 riskAssessment: analysis.riskAssessment,
518 insights: analysis.insights
519 )
520 }
521
522 let entry = HistoryEntry(
523 identity: HistoryEntry.Identity(
524 domain: snapshot.domain,
525 timestamp: snapshot.timestamp,
526 trackedDomainID: trackedDomainID,
527 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note
528 ),
529 inspection: .init(snapshot: snapshot),
530 registration: HistoryEntry.Registration(
531 ownership: snapshot.ownership,
532 ownershipHistory: snapshot.ownershipHistory,
533 domainPricing: snapshot.domainPricing
534 ),
535 intelligence: HistoryEntry.Intelligence(
536 reputation: snapshot.reputation
537 ),
538 provenance: .init(snapshot: snapshot),
539 summary: HistoryEntry.Summary(
540 primaryIP: Self.primaryIPAddress(from: snapshot),
541 finalRedirectURL: snapshot.redirectChain.last?.url,
542 tlsStatusSummary: Self.tlsSummary(from: snapshot),
543 emailSecuritySummary: Self.emailSummary(from: snapshot),
544 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
545 changeSummary: changeSummary
546 ),
547 failures: .init(snapshot: snapshot)
548 )
549
550 history.insert(entry, at: 0)
551 if history.count > maxHistoryEntries {
552 history = Array(history.prefix(maxHistoryEntries))
553 }
554
555 if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) {
556 trackedDomains[index].lastSnapshotID = entry.id
557 trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status
558 trackedDomains[index].updatedAt = snapshot.timestamp
559 trackedDomains[index].lastChangeSummary = changeSummary
560 trackedDomains[index].lastChangeSeverity = changeSummary?.severity
561 trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
562 trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry
563 }
564
565 return entry
566 }
567
568 private func shouldInspect(
569 trackedDomain: TrackedDomain,
570 trigger: MonitoringRunTrigger,
571 settings: MonitoringSettings
572 ) -> Bool {
573 guard trigger == .background else { return true }
574 guard let lastCheck = trackedDomain.monitoringState.lastCheck else { return true }
575 let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
576 return lastCheck.addingTimeInterval(state.currentInterval) <= Date()
577 }
578
579 private func processAdaptiveMonitoringState(
580 trackedDomain: TrackedDomain,
581 snapshot _: LookupSnapshot,
582 previousSnapshot _: LookupSnapshot?,
583 alertDescriptor: MonitoringAlertDescriptor?,
584 settings: MonitoringSettings,
585 notificationsAuthorized: Bool,
586 now: Date
587 ) async -> (trackedDomain: TrackedDomain, alertsTriggered: Int) {
588 var updated = trackedDomain
589 var state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
590 let baseInterval = settings.config.sanitizedBaseInterval
591 let minInterval = settings.sensitivity.minimumInterval
592 let maxInterval = settings.config.maxInterval
593 let isQuiet = settings.quietHours?.contains(now, calendar: calendar) == true
594 var alertsTriggered = 0
595
596 if let alertDescriptor {
597 if state.lastChangeHash != alertDescriptor.changeHash {
598 state.lastChangeHash = alertDescriptor.changeHash
599 state.lastChangeDate = now
600 state.consecutiveStableChecks = 0
601 state.currentInterval = settings.adaptiveEnabled ? minInterval : baseInterval
602
603 if settings.alertsEnabled,
604 alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
605 let pendingAlert = MonitoringPendingAlert(
606 detectedAt: now,
607 message: alertDescriptor.message,
608 severity: alertDescriptor.severity,
609 changeHash: alertDescriptor.changeHash
610 )
611
612 if isQuiet || !notificationsAuthorized {
613 updated.pendingMonitoringAlerts = deduplicatedPendingAlerts(
614 updated.pendingMonitoringAlerts + [pendingAlert]
615 )
616 } else {
617 let pendingAlerts = deduplicatedPendingAlerts(
618 updated.pendingMonitoringAlerts + [pendingAlert]
619 )
620 if pendingAlerts.count > 1 {
621 await LocalNotificationService.shared.notifyMonitoringSummary(
622 domain: trackedDomain.domain,
623 alerts: pendingAlerts
624 )
625 } else {
626 await LocalNotificationService.shared.notifyMonitoringAlert(
627 domain: trackedDomain.domain,
628 message: alertDescriptor.message,
629 severity: alertDescriptor.severity
630 )
631 }
632 alertsTriggered += 1
633 updated.pendingMonitoringAlerts = []
634 updated.lastAlertAt = now
635 state.lastAlertDate = now
636 }
637 }
638 }
639 } else {
640 state.consecutiveStableChecks += 1
641 if settings.adaptiveEnabled {
642 state.currentInterval = max(
643 baseInterval,
644 min(maxInterval, state.currentInterval * settings.sensitivity.intervalMultiplier)
645 )
646 } else {
647 state.currentInterval = baseInterval
648 }
649 }
650
651 if !isQuiet,
652 settings.alertsEnabled,
653 notificationsAuthorized,
654 !updated.pendingMonitoringAlerts.isEmpty,
655 alertDescriptor == nil {
656 await LocalNotificationService.shared.notifyMonitoringSummary(
657 domain: trackedDomain.domain,
658 alerts: updated.pendingMonitoringAlerts
659 )
660 alertsTriggered += 1
661 updated.pendingMonitoringAlerts = []
662 updated.lastAlertAt = now
663 state.lastAlertDate = now
664 }
665
666 state.lastCheck = now
667 updated.monitoringState = state
668 return (updated, alertsTriggered)
669 }
670
671 private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
672 var uniqueByHash: [String: MonitoringPendingAlert] = [:]
673 for alert in alerts {
674 if let existing = uniqueByHash[alert.changeHash] {
675 uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
676 } else {
677 uniqueByHash[alert.changeHash] = alert
678 }
679 }
680 return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
681 }
682
683 private struct MonitoringAlertDescriptor {
684 let severity: MonitoringAlertSeverity
685 let message: String
686 let changeHash: String
687 }
688
689 private struct HeaderFingerprint: Encodable, Equatable {
690 let name: String
691 let value: String
692 }
693
694 private func alertDescriptor(
695 previousSnapshot: LookupSnapshot?,
696 snapshot: LookupSnapshot,
697 entry: HistoryEntry?,
698 sensitivity: MonitoringSensitivity
699 ) -> MonitoringAlertDescriptor? {
700 guard let previousSnapshot else { return nil }
701 let previousHash = monitoringHash(for: previousSnapshot, sensitivity: sensitivity)
702 let currentHash = monitoringHash(for: snapshot, sensitivity: sensitivity)
703 guard previousHash != currentHash else { return nil }
704
705 let changedLabels = Set(
706 DomainDiffService.diff(from: previousSnapshot, to: snapshot)
707 .flatMap(\.items)
708 .filter(\.hasChanges)
709 .map(\.label)
710 )
711
712 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
713 return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash)
714 }
715
716 let oldCertificateLevel = DomainDiffService.certificateWarningLevel(for: previousSnapshot)
717 let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
718 if snapshot.sslInfo?.issuer != previousSnapshot.sslInfo?.issuer
719 || snapshot.sslInfo?.validUntil != previousSnapshot.sslInfo?.validUntil
720 || (newCertificateLevel != .none && newCertificateLevel != oldCertificateLevel) {
721 return MonitoringAlertDescriptor(severity: .warning, message: "Certificate updated", changeHash: currentHash)
722 }
723
724 if sensitivity != .low,
725 previousSnapshot.redirectChain.map(\.url) != snapshot.redirectChain.map(\.url)
726 || previousSnapshot.redirectChain.map(\.statusCode) != snapshot.redirectChain.map(\.statusCode) {
727 return MonitoringAlertDescriptor(severity: .warning, message: "Redirect chain changed", changeHash: currentHash)
728 }
729
730 if sensitivity != .low,
731 filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity)
732 != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) {
733 return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash)
734 }
735
736 if sensitivity == .high,
737 entry?.changeSummary?.hasChanges == true,
738 let message = entry?.changeSummary?.message {
739 return MonitoringAlertDescriptor(severity: .info, message: message, changeHash: currentHash)
740 }
741
742 return nil
743 }
744
745 private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String {
746 struct DNSRecordPayload: Encodable {
747 let type: String
748 let records: [String]
749 let wildcardRecords: [String]
750 }
751
752 struct CertificatePayload: Encodable {
753 let issuer: String?
754 let commonName: String?
755 let validUntil: Date?
756 }
757
758 struct RedirectPayload: Encodable {
759 let url: String
760 let statusCode: Int
761 }
762
763 struct HeaderPayload: Encodable {
764 let name: String
765 let value: String
766 }
767
768 struct MonitoringHashPayload: Encodable {
769 let dns: [DNSRecordPayload]
770 let certificate: CertificatePayload
771 let headers: [HeaderPayload]
772 let redirects: [RedirectPayload]
773 }
774
775 let payload = MonitoringHashPayload(
776 dns: snapshot.dnsSections
777 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
778 .map { section in
779 DNSRecordPayload(
780 type: section.recordType.rawValue,
781 records: section.records
782 .sorted { lhs, rhs in
783 lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
784 }
785 .map { "\($0.value)|\($0.ttl)" },
786 wildcardRecords: section.wildcardRecords
787 .sorted { lhs, rhs in
788 lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
789 }
790 .map { "\($0.value)|\($0.ttl)" }
791 )
792 },
793 certificate: CertificatePayload(
794 issuer: snapshot.sslInfo?.issuer,
795 commonName: snapshot.sslInfo?.commonName,
796 validUntil: snapshot.sslInfo?.validUntil
797 ),
798 headers: filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity).map {
799 HeaderPayload(name: $0.name, value: $0.value)
800 },
801 redirects: sensitivity == .low ? [] : snapshot.redirectChain.map {
802 RedirectPayload(url: $0.url, statusCode: $0.statusCode)
803 }
804 )
805
806 let encoder = JSONEncoder()
807 if #available(iOS 11.0, macOS 10.13, *) {
808 encoder.outputFormatting = [.sortedKeys]
809 }
810 let data = (try? encoder.encode(payload)) ?? Data()
811 return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
812 }
813
814 private func filteredHTTPHeaders(
815 _ headers: [HTTPHeader],
816 sensitivity: MonitoringSensitivity
817 ) -> [HeaderFingerprint] {
818 let mediumHeaderNames: Set<String> = [
819 "cache-control",
820 "content-security-policy",
821 "location",
822 "permissions-policy",
823 "referrer-policy",
824 "server",
825 "strict-transport-security",
826 "x-content-type-options",
827 "x-frame-options"
828 ]
829
830 return headers
831 .filter { header in
832 switch sensitivity {
833 case .low:
834 return false
835 case .medium:
836 return mediumHeaderNames.contains(header.name.lowercased())
837 case .high:
838 return true
839 }
840 }
841 .map { HeaderFingerprint(name: $0.name.lowercased(), value: $0.value) }
842 .sorted { lhs, rhs in
843 lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name
844 }
845 }
846
847 private static func resolvedSnapshotAfterFallback(
848 _ snapshot: LookupSnapshot,
849 previousSnapshot: LookupSnapshot?
850 ) -> LookupSnapshot {
851 guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
852 return snapshot
853 }
854
855 return LookupSnapshot(
856 historyEntryID: previousSnapshot.historyEntryID,
857 domain: previousSnapshot.domain,
858 timestamp: previousSnapshot.timestamp,
859 trackedDomainID: previousSnapshot.trackedDomainID,
860 note: previousSnapshot.note,
861 appVersion: previousSnapshot.appVersion,
862 resolverDisplayName: previousSnapshot.resolverDisplayName,
863 resolverURLString: previousSnapshot.resolverURLString,
864 dataSources: previousSnapshot.dataSources,
865 provenanceBySection: previousSnapshot.provenanceBySection,
866 availabilityConfidence: previousSnapshot.availabilityConfidence,
867 ownershipConfidence: previousSnapshot.ownershipConfidence,
868 subdomainConfidence: previousSnapshot.subdomainConfidence,
869 emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
870 geolocationConfidence: previousSnapshot.geolocationConfidence,
871 errorDetails: previousSnapshot.errorDetails,
872 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
873 validationIssues: previousSnapshot.validationIssues,
874 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
875 snapshotIndex: previousSnapshot.snapshotIndex,
876 previousSnapshotID: previousSnapshot.previousSnapshotID,
877 changeCount: previousSnapshot.changeCount,
878 severitySummary: previousSnapshot.severitySummary,
879 dnsSections: previousSnapshot.dnsSections,
880 dnsError: previousSnapshot.dnsError,
881 availabilityResult: previousSnapshot.availabilityResult,
882 suggestions: previousSnapshot.suggestions,
883 sslInfo: previousSnapshot.sslInfo,
884 sslError: previousSnapshot.sslError,
885 hstsPreloaded: previousSnapshot.hstsPreloaded,
886 httpHeaders: previousSnapshot.httpHeaders,
887 httpSecurityGrade: previousSnapshot.httpSecurityGrade,
888 httpStatusCode: previousSnapshot.httpStatusCode,
889 httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
890 httpProtocol: previousSnapshot.httpProtocol,
891 http3Advertised: previousSnapshot.http3Advertised,
892 httpHeadersError: previousSnapshot.httpHeadersError,
893 reachabilityResults: previousSnapshot.reachabilityResults,
894 reachabilityError: previousSnapshot.reachabilityError,
895 ipGeolocation: previousSnapshot.ipGeolocation,
896 ipGeolocationError: previousSnapshot.ipGeolocationError,
897 emailSecurity: previousSnapshot.emailSecurity,
898 emailSecurityError: previousSnapshot.emailSecurityError,
899 ownership: previousSnapshot.ownership,
900 ownershipError: previousSnapshot.ownershipError,
901 ownershipHistory: previousSnapshot.ownershipHistory,
902 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
903 inferredProvider: previousSnapshot.inferredProvider,
904 priorProviders: previousSnapshot.priorProviders,
905 domainClassification: previousSnapshot.domainClassification,
906 ownershipTransitions: previousSnapshot.ownershipTransitions,
907 hostingTransitions: previousSnapshot.hostingTransitions,
908 subdomainHistory: previousSnapshot.subdomainHistory,
909 riskSignals: previousSnapshot.riskSignals,
910 intelligenceTimeline: previousSnapshot.intelligenceTimeline,
911 ptrRecord: previousSnapshot.ptrRecord,
912 ptrError: previousSnapshot.ptrError,
913 redirectChain: previousSnapshot.redirectChain,
914 redirectChainError: previousSnapshot.redirectChainError,
915 subdomains: previousSnapshot.subdomains,
916 subdomainsError: previousSnapshot.subdomainsError,
917 extendedSubdomains: previousSnapshot.extendedSubdomains,
918 extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
919 dnsHistory: previousSnapshot.dnsHistory,
920 dnsHistoryError: previousSnapshot.dnsHistoryError,
921 domainPricing: previousSnapshot.domainPricing,
922 domainPricingError: previousSnapshot.domainPricingError,
923 reputation: previousSnapshot.reputation,
924 reputationError: previousSnapshot.reputationError,
925 portScanResults: previousSnapshot.portScanResults,
926 portScanError: previousSnapshot.portScanError,
927 changeSummary: previousSnapshot.changeSummary,
928 resultSource: .snapshot,
929 cachedSections: [],
930 statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
931 )
932 }
933
934 /// First reported error on a snapshot, used to explain a fallback. Mirrors
935 /// the ordering `shouldFallbackToSnapshot` inspects.
936 private static func firstErrorMessage(in snapshot: LookupSnapshot) -> String {
937 [
938 snapshot.dnsError,
939 snapshot.httpHeadersError,
940 snapshot.sslError,
941 snapshot.ownershipError,
942 snapshot.subdomainsError,
943 snapshot.redirectChainError,
944 snapshot.ipGeolocationError
945 ]
946 .compactMap { $0 }
947 .first ?? "The lookup could not reach the domain."
948 }
949
950 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
951 let candidateMessages = [
952 snapshot.dnsError,
953 snapshot.httpHeadersError,
954 snapshot.sslError,
955 snapshot.ownershipError,
956 snapshot.subdomainsError,
957 snapshot.redirectChainError,
958 snapshot.ipGeolocationError
959 ]
960 .compactMap { $0?.lowercased() }
961
962 guard !candidateMessages.isEmpty else { return false }
963 let connectivityFailure = candidateMessages.allSatisfy { message in
964 message.hasPrefix("network error:")
965 || message.hasPrefix("timeout:")
966 || message.hasPrefix("rate limit:")
967 }
968
969 let hasMaterialData = !snapshot.dnsSections.isEmpty
970 || !snapshot.httpHeaders.isEmpty
971 || snapshot.sslInfo != nil
972 || snapshot.ownership != nil
973 || !snapshot.subdomains.isEmpty
974
975 return connectivityFailure && !hasMaterialData
976 }
977
978 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
979 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
980 }
981
982 private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
983 guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError }
984 switch DomainDiffService.certificateWarningLevel(for: snapshot) {
985 case .critical:
986 return "Critical (\(sslInfo.daysUntilExpiry)d)"
987 case .warning:
988 return "Warning (\(sslInfo.daysUntilExpiry)d)"
989 case .none:
990 return "Healthy (\(sslInfo.daysUntilExpiry)d)"
991 }
992 }
993
994 private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
995 if let emailSecurity = snapshot.emailSecurity {
996 return [
997 "spf:\(emailSecurity.spf.found)",
998 "dmarc:\(emailSecurity.dmarc.found)",
999 "dkim:\(emailSecurity.dkim.found)",
1000 "bimi:\(emailSecurity.bimi.found)",
1001 "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
1002 ].joined(separator: "|")
1003 }
1004 return snapshot.emailSecurityError
1005 }
1006}