krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.9.0: 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 domain: snapshot.domain,
524 timestamp: snapshot.timestamp,
525 trackedDomainID: trackedDomainID,
526 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note,
527 dnsSections: snapshot.dnsSections,
528 sslInfo: snapshot.sslInfo,
529 httpHeaders: snapshot.httpHeaders,
530 reachabilityResults: snapshot.reachabilityResults,
531 ipGeolocation: snapshot.ipGeolocation,
532 emailSecurity: snapshot.emailSecurity,
533 mtaSts: snapshot.emailSecurity?.mtaSts,
534 ownership: snapshot.ownership,
535 ownershipHistory: snapshot.ownershipHistory,
536 ptrRecord: snapshot.ptrRecord,
537 redirectChain: snapshot.redirectChain,
538 subdomains: snapshot.subdomains,
539 extendedSubdomains: snapshot.extendedSubdomains,
540 dnsHistory: snapshot.dnsHistory,
541 domainPricing: snapshot.domainPricing,
542 reputation: snapshot.reputation,
543 portScanResults: snapshot.portScanResults,
544 hstsPreloaded: snapshot.hstsPreloaded,
545 availabilityResult: snapshot.availabilityResult,
546 suggestions: snapshot.suggestions,
547 appVersion: snapshot.appVersion,
548 resultSource: snapshot.resultSource,
549 dataSources: snapshot.dataSources,
550 provenanceBySection: snapshot.provenanceBySection,
551 availabilityConfidence: snapshot.availabilityConfidence,
552 ownershipConfidence: snapshot.ownershipConfidence,
553 subdomainConfidence: snapshot.subdomainConfidence,
554 emailSecurityConfidence: snapshot.emailSecurityConfidence,
555 geolocationConfidence: snapshot.geolocationConfidence,
556 errorDetails: snapshot.errorDetails,
557 isPartialSnapshot: snapshot.isPartialSnapshot,
558 validationIssues: snapshot.validationIssues,
559 resolverDisplayName: snapshot.resolverDisplayName,
560 resolverURLString: snapshot.resolverURLString,
561 totalLookupDurationMs: snapshot.totalLookupDurationMs,
562 primaryIP: Self.primaryIPAddress(from: snapshot),
563 finalRedirectURL: snapshot.redirectChain.last?.url,
564 tlsStatusSummary: Self.tlsSummary(from: snapshot),
565 emailSecuritySummary: Self.emailSummary(from: snapshot),
566 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
567 changeSummary: changeSummary,
568 sslError: snapshot.sslError,
569 httpHeadersError: snapshot.httpHeadersError,
570 reachabilityError: snapshot.reachabilityError,
571 ipGeolocationError: snapshot.ipGeolocationError,
572 emailSecurityError: snapshot.emailSecurityError,
573 ownershipError: snapshot.ownershipError,
574 ownershipHistoryError: snapshot.ownershipHistoryError,
575 ptrError: snapshot.ptrError,
576 redirectChainError: snapshot.redirectChainError,
577 subdomainsError: snapshot.subdomainsError,
578 extendedSubdomainsError: snapshot.extendedSubdomainsError,
579 dnsHistoryError: snapshot.dnsHistoryError,
580 domainPricingError: snapshot.domainPricingError,
581 reputationError: snapshot.reputationError,
582 portScanError: snapshot.portScanError
583 )
584
585 history.insert(entry, at: 0)
586 if history.count > maxHistoryEntries {
587 history = Array(history.prefix(maxHistoryEntries))
588 }
589
590 if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) {
591 trackedDomains[index].lastSnapshotID = entry.id
592 trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status
593 trackedDomains[index].updatedAt = snapshot.timestamp
594 trackedDomains[index].lastChangeSummary = changeSummary
595 trackedDomains[index].lastChangeSeverity = changeSummary?.severity
596 trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
597 trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry
598 }
599
600 return entry
601 }
602
603 private func shouldInspect(
604 trackedDomain: TrackedDomain,
605 trigger: MonitoringRunTrigger,
606 settings: MonitoringSettings
607 ) -> Bool {
608 guard trigger == .background else { return true }
609 guard let lastCheck = trackedDomain.monitoringState.lastCheck else { return true }
610 let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
611 return lastCheck.addingTimeInterval(state.currentInterval) <= Date()
612 }
613
614 private func processAdaptiveMonitoringState(
615 trackedDomain: TrackedDomain,
616 snapshot _: LookupSnapshot,
617 previousSnapshot _: LookupSnapshot?,
618 alertDescriptor: MonitoringAlertDescriptor?,
619 settings: MonitoringSettings,
620 notificationsAuthorized: Bool,
621 now: Date
622 ) async -> (trackedDomain: TrackedDomain, alertsTriggered: Int) {
623 var updated = trackedDomain
624 var state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
625 let baseInterval = settings.config.sanitizedBaseInterval
626 let minInterval = settings.sensitivity.minimumInterval
627 let maxInterval = settings.config.maxInterval
628 let isQuiet = settings.quietHours?.contains(now, calendar: calendar) == true
629 var alertsTriggered = 0
630
631 if let alertDescriptor {
632 if state.lastChangeHash != alertDescriptor.changeHash {
633 state.lastChangeHash = alertDescriptor.changeHash
634 state.lastChangeDate = now
635 state.consecutiveStableChecks = 0
636 state.currentInterval = settings.adaptiveEnabled ? minInterval : baseInterval
637
638 if settings.alertsEnabled,
639 alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
640 let pendingAlert = MonitoringPendingAlert(
641 detectedAt: now,
642 message: alertDescriptor.message,
643 severity: alertDescriptor.severity,
644 changeHash: alertDescriptor.changeHash
645 )
646
647 if isQuiet || !notificationsAuthorized {
648 updated.pendingMonitoringAlerts = deduplicatedPendingAlerts(
649 updated.pendingMonitoringAlerts + [pendingAlert]
650 )
651 } else {
652 let pendingAlerts = deduplicatedPendingAlerts(
653 updated.pendingMonitoringAlerts + [pendingAlert]
654 )
655 if pendingAlerts.count > 1 {
656 await LocalNotificationService.shared.notifyMonitoringSummary(
657 domain: trackedDomain.domain,
658 alerts: pendingAlerts
659 )
660 } else {
661 await LocalNotificationService.shared.notifyMonitoringAlert(
662 domain: trackedDomain.domain,
663 message: alertDescriptor.message,
664 severity: alertDescriptor.severity
665 )
666 }
667 alertsTriggered += 1
668 updated.pendingMonitoringAlerts = []
669 updated.lastAlertAt = now
670 state.lastAlertDate = now
671 }
672 }
673 }
674 } else {
675 state.consecutiveStableChecks += 1
676 if settings.adaptiveEnabled {
677 state.currentInterval = max(
678 baseInterval,
679 min(maxInterval, state.currentInterval * settings.sensitivity.intervalMultiplier)
680 )
681 } else {
682 state.currentInterval = baseInterval
683 }
684 }
685
686 if !isQuiet,
687 settings.alertsEnabled,
688 notificationsAuthorized,
689 !updated.pendingMonitoringAlerts.isEmpty,
690 alertDescriptor == nil {
691 await LocalNotificationService.shared.notifyMonitoringSummary(
692 domain: trackedDomain.domain,
693 alerts: updated.pendingMonitoringAlerts
694 )
695 alertsTriggered += 1
696 updated.pendingMonitoringAlerts = []
697 updated.lastAlertAt = now
698 state.lastAlertDate = now
699 }
700
701 state.lastCheck = now
702 updated.monitoringState = state
703 return (updated, alertsTriggered)
704 }
705
706 private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
707 var uniqueByHash: [String: MonitoringPendingAlert] = [:]
708 for alert in alerts {
709 if let existing = uniqueByHash[alert.changeHash] {
710 uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
711 } else {
712 uniqueByHash[alert.changeHash] = alert
713 }
714 }
715 return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
716 }
717
718 private struct MonitoringAlertDescriptor {
719 let severity: MonitoringAlertSeverity
720 let message: String
721 let changeHash: String
722 }
723
724 private struct HeaderFingerprint: Encodable, Equatable {
725 let name: String
726 let value: String
727 }
728
729 private func alertDescriptor(
730 previousSnapshot: LookupSnapshot?,
731 snapshot: LookupSnapshot,
732 entry: HistoryEntry?,
733 sensitivity: MonitoringSensitivity
734 ) -> MonitoringAlertDescriptor? {
735 guard let previousSnapshot else { return nil }
736 let previousHash = monitoringHash(for: previousSnapshot, sensitivity: sensitivity)
737 let currentHash = monitoringHash(for: snapshot, sensitivity: sensitivity)
738 guard previousHash != currentHash else { return nil }
739
740 let changedLabels = Set(
741 DomainDiffService.diff(from: previousSnapshot, to: snapshot)
742 .flatMap(\.items)
743 .filter(\.hasChanges)
744 .map(\.label)
745 )
746
747 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
748 return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash)
749 }
750
751 let oldCertificateLevel = DomainDiffService.certificateWarningLevel(for: previousSnapshot)
752 let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
753 if snapshot.sslInfo?.issuer != previousSnapshot.sslInfo?.issuer
754 || snapshot.sslInfo?.validUntil != previousSnapshot.sslInfo?.validUntil
755 || (newCertificateLevel != .none && newCertificateLevel != oldCertificateLevel) {
756 return MonitoringAlertDescriptor(severity: .warning, message: "Certificate updated", changeHash: currentHash)
757 }
758
759 if sensitivity != .low,
760 previousSnapshot.redirectChain.map(\.url) != snapshot.redirectChain.map(\.url)
761 || previousSnapshot.redirectChain.map(\.statusCode) != snapshot.redirectChain.map(\.statusCode) {
762 return MonitoringAlertDescriptor(severity: .warning, message: "Redirect chain changed", changeHash: currentHash)
763 }
764
765 if sensitivity != .low,
766 filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity)
767 != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) {
768 return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash)
769 }
770
771 if sensitivity == .high,
772 entry?.changeSummary?.hasChanges == true,
773 let message = entry?.changeSummary?.message {
774 return MonitoringAlertDescriptor(severity: .info, message: message, changeHash: currentHash)
775 }
776
777 return nil
778 }
779
780 private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String {
781 struct DNSRecordPayload: Encodable {
782 let type: String
783 let records: [String]
784 let wildcardRecords: [String]
785 }
786
787 struct CertificatePayload: Encodable {
788 let issuer: String?
789 let commonName: String?
790 let validUntil: Date?
791 }
792
793 struct RedirectPayload: Encodable {
794 let url: String
795 let statusCode: Int
796 }
797
798 struct HeaderPayload: Encodable {
799 let name: String
800 let value: String
801 }
802
803 struct MonitoringHashPayload: Encodable {
804 let dns: [DNSRecordPayload]
805 let certificate: CertificatePayload
806 let headers: [HeaderPayload]
807 let redirects: [RedirectPayload]
808 }
809
810 let payload = MonitoringHashPayload(
811 dns: snapshot.dnsSections
812 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
813 .map { section in
814 DNSRecordPayload(
815 type: section.recordType.rawValue,
816 records: section.records
817 .sorted { lhs, rhs in
818 lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
819 }
820 .map { "\($0.value)|\($0.ttl)" },
821 wildcardRecords: section.wildcardRecords
822 .sorted { lhs, rhs in
823 lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
824 }
825 .map { "\($0.value)|\($0.ttl)" }
826 )
827 },
828 certificate: CertificatePayload(
829 issuer: snapshot.sslInfo?.issuer,
830 commonName: snapshot.sslInfo?.commonName,
831 validUntil: snapshot.sslInfo?.validUntil
832 ),
833 headers: filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity).map {
834 HeaderPayload(name: $0.name, value: $0.value)
835 },
836 redirects: sensitivity == .low ? [] : snapshot.redirectChain.map {
837 RedirectPayload(url: $0.url, statusCode: $0.statusCode)
838 }
839 )
840
841 let encoder = JSONEncoder()
842 if #available(iOS 11.0, macOS 10.13, *) {
843 encoder.outputFormatting = [.sortedKeys]
844 }
845 let data = (try? encoder.encode(payload)) ?? Data()
846 return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
847 }
848
849 private func filteredHTTPHeaders(
850 _ headers: [HTTPHeader],
851 sensitivity: MonitoringSensitivity
852 ) -> [HeaderFingerprint] {
853 let mediumHeaderNames: Set<String> = [
854 "cache-control",
855 "content-security-policy",
856 "location",
857 "permissions-policy",
858 "referrer-policy",
859 "server",
860 "strict-transport-security",
861 "x-content-type-options",
862 "x-frame-options"
863 ]
864
865 return headers
866 .filter { header in
867 switch sensitivity {
868 case .low:
869 return false
870 case .medium:
871 return mediumHeaderNames.contains(header.name.lowercased())
872 case .high:
873 return true
874 }
875 }
876 .map { HeaderFingerprint(name: $0.name.lowercased(), value: $0.value) }
877 .sorted { lhs, rhs in
878 lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name
879 }
880 }
881
882 private static func resolvedSnapshotAfterFallback(
883 _ snapshot: LookupSnapshot,
884 previousSnapshot: LookupSnapshot?
885 ) -> LookupSnapshot {
886 guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
887 return snapshot
888 }
889
890 return LookupSnapshot(
891 historyEntryID: previousSnapshot.historyEntryID,
892 domain: previousSnapshot.domain,
893 timestamp: previousSnapshot.timestamp,
894 trackedDomainID: previousSnapshot.trackedDomainID,
895 note: previousSnapshot.note,
896 appVersion: previousSnapshot.appVersion,
897 resolverDisplayName: previousSnapshot.resolverDisplayName,
898 resolverURLString: previousSnapshot.resolverURLString,
899 dataSources: previousSnapshot.dataSources,
900 provenanceBySection: previousSnapshot.provenanceBySection,
901 availabilityConfidence: previousSnapshot.availabilityConfidence,
902 ownershipConfidence: previousSnapshot.ownershipConfidence,
903 subdomainConfidence: previousSnapshot.subdomainConfidence,
904 emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
905 geolocationConfidence: previousSnapshot.geolocationConfidence,
906 errorDetails: previousSnapshot.errorDetails,
907 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
908 validationIssues: previousSnapshot.validationIssues,
909 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
910 snapshotIndex: previousSnapshot.snapshotIndex,
911 previousSnapshotID: previousSnapshot.previousSnapshotID,
912 changeCount: previousSnapshot.changeCount,
913 severitySummary: previousSnapshot.severitySummary,
914 dnsSections: previousSnapshot.dnsSections,
915 dnsError: previousSnapshot.dnsError,
916 availabilityResult: previousSnapshot.availabilityResult,
917 suggestions: previousSnapshot.suggestions,
918 sslInfo: previousSnapshot.sslInfo,
919 sslError: previousSnapshot.sslError,
920 hstsPreloaded: previousSnapshot.hstsPreloaded,
921 httpHeaders: previousSnapshot.httpHeaders,
922 httpSecurityGrade: previousSnapshot.httpSecurityGrade,
923 httpStatusCode: previousSnapshot.httpStatusCode,
924 httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
925 httpProtocol: previousSnapshot.httpProtocol,
926 http3Advertised: previousSnapshot.http3Advertised,
927 httpHeadersError: previousSnapshot.httpHeadersError,
928 reachabilityResults: previousSnapshot.reachabilityResults,
929 reachabilityError: previousSnapshot.reachabilityError,
930 ipGeolocation: previousSnapshot.ipGeolocation,
931 ipGeolocationError: previousSnapshot.ipGeolocationError,
932 emailSecurity: previousSnapshot.emailSecurity,
933 emailSecurityError: previousSnapshot.emailSecurityError,
934 ownership: previousSnapshot.ownership,
935 ownershipError: previousSnapshot.ownershipError,
936 ownershipHistory: previousSnapshot.ownershipHistory,
937 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
938 inferredProvider: previousSnapshot.inferredProvider,
939 priorProviders: previousSnapshot.priorProviders,
940 domainClassification: previousSnapshot.domainClassification,
941 ownershipTransitions: previousSnapshot.ownershipTransitions,
942 hostingTransitions: previousSnapshot.hostingTransitions,
943 subdomainHistory: previousSnapshot.subdomainHistory,
944 riskSignals: previousSnapshot.riskSignals,
945 intelligenceTimeline: previousSnapshot.intelligenceTimeline,
946 ptrRecord: previousSnapshot.ptrRecord,
947 ptrError: previousSnapshot.ptrError,
948 redirectChain: previousSnapshot.redirectChain,
949 redirectChainError: previousSnapshot.redirectChainError,
950 subdomains: previousSnapshot.subdomains,
951 subdomainsError: previousSnapshot.subdomainsError,
952 extendedSubdomains: previousSnapshot.extendedSubdomains,
953 extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
954 dnsHistory: previousSnapshot.dnsHistory,
955 dnsHistoryError: previousSnapshot.dnsHistoryError,
956 domainPricing: previousSnapshot.domainPricing,
957 domainPricingError: previousSnapshot.domainPricingError,
958 reputation: previousSnapshot.reputation,
959 reputationError: previousSnapshot.reputationError,
960 portScanResults: previousSnapshot.portScanResults,
961 portScanError: previousSnapshot.portScanError,
962 changeSummary: previousSnapshot.changeSummary,
963 resultSource: .snapshot,
964 cachedSections: [],
965 statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
966 )
967 }
968
969 /// First reported error on a snapshot, used to explain a fallback. Mirrors
970 /// the ordering `shouldFallbackToSnapshot` inspects.
971 private static func firstErrorMessage(in snapshot: LookupSnapshot) -> String {
972 [
973 snapshot.dnsError,
974 snapshot.httpHeadersError,
975 snapshot.sslError,
976 snapshot.ownershipError,
977 snapshot.subdomainsError,
978 snapshot.redirectChainError,
979 snapshot.ipGeolocationError
980 ]
981 .compactMap { $0 }
982 .first ?? "The lookup could not reach the domain."
983 }
984
985 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
986 let candidateMessages = [
987 snapshot.dnsError,
988 snapshot.httpHeadersError,
989 snapshot.sslError,
990 snapshot.ownershipError,
991 snapshot.subdomainsError,
992 snapshot.redirectChainError,
993 snapshot.ipGeolocationError
994 ]
995 .compactMap { $0?.lowercased() }
996
997 guard !candidateMessages.isEmpty else { return false }
998 let connectivityFailure = candidateMessages.allSatisfy { message in
999 message.hasPrefix("network error:")
1000 || message.hasPrefix("timeout:")
1001 || message.hasPrefix("rate limit:")
1002 }
1003
1004 let hasMaterialData = !snapshot.dnsSections.isEmpty
1005 || !snapshot.httpHeaders.isEmpty
1006 || snapshot.sslInfo != nil
1007 || snapshot.ownership != nil
1008 || !snapshot.subdomains.isEmpty
1009
1010 return connectivityFailure && !hasMaterialData
1011 }
1012
1013 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
1014 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
1015 }
1016
1017 private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
1018 guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError }
1019 switch DomainDiffService.certificateWarningLevel(for: snapshot) {
1020 case .critical:
1021 return "Critical (\(sslInfo.daysUntilExpiry)d)"
1022 case .warning:
1023 return "Warning (\(sslInfo.daysUntilExpiry)d)"
1024 case .none:
1025 return "Healthy (\(sslInfo.daysUntilExpiry)d)"
1026 }
1027 }
1028
1029 private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
1030 if let emailSecurity = snapshot.emailSecurity {
1031 return [
1032 "spf:\(emailSecurity.spf.found)",
1033 "dmarc:\(emailSecurity.dmarc.found)",
1034 "dkim:\(emailSecurity.dkim.found)",
1035 "bimi:\(emailSecurity.bimi.found)",
1036 "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
1037 ].joined(separator: "|")
1038 }
1039 return snapshot.emailSecurityError
1040 }
1041}