krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.4.1: 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() {}
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 let savedEntry: HistoryEntry?
295 if snapshot.statusMessage == nil {
296 savedEntry = persistSnapshot(
297 snapshot,
298 trackedDomainID: trackedDomain.id,
299 trackedDomains: &mutableTrackedDomains,
300 history: &history
301 )
302 } else {
303 savedEntry = history.first(where: { $0.id == snapshot.historyEntryID })
304 }
305
306 let alertDescriptor = alertDescriptor(
307 previousSnapshot: previousSnapshot,
308 snapshot: snapshot,
309 entry: savedEntry,
310 sensitivity: settings.sensitivity
311 )
312
313 if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
314 mutableTrackedDomains[index].lastMonitoredAt = now
315 let outcome = await processAdaptiveMonitoringState(
316 trackedDomain: mutableTrackedDomains[index],
317 snapshot: snapshot,
318 previousSnapshot: previousSnapshot,
319 alertDescriptor: alertDescriptor,
320 settings: settings,
321 notificationsAuthorized: notificationsAuthorized,
322 now: now
323 )
324 mutableTrackedDomains[index] = outcome.trackedDomain
325 alertsTriggered += outcome.alertsTriggered
326 }
327
328 let result = MonitoringDomainResult(
329 domain: trackedDomain.domain,
330 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
331 checkedAt: now,
332 didChange: alertDescriptor != nil,
333 summaryMessage: snapshot.statusMessage
334 ?? alertDescriptor?.message
335 ?? savedEntry?.changeSummary?.message
336 ?? "No meaningful changes",
337 alertSeverity: alertDescriptor?.severity,
338 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
339 resultSource: snapshot.resultSource,
340 errorMessage: snapshot.statusMessage
341 )
342 results.append(result)
343
344 if let errorMessage = result.errorMessage {
345 errors.append("\(trackedDomain.domain): \(errorMessage)")
346 }
347 }
348
349 MonitoringStorage.saveTrackedDomains(mutableTrackedDomains)
350 MonitoringStorage.saveHistoryEntries(history)
351
352 let log = MonitoringLog(
353 timestamp: Date(),
354 trigger: trigger,
355 domainsChecked: results.count,
356 changesFound: results.filter(\.didChange).count,
357 alertsTriggered: alertsTriggered,
358 checkedDomains: results.sorted {
359 $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
360 },
361 errors: errors
362 )
363 saveLog(log)
364 let outboundEvents = monitoringEvents(from: log)
365 if outboundEvents.isEmpty {
366 IntegrationService.shared.recordNoOutboundEvents(for: log.summary)
367 } else {
368 IntegrationService.shared.enqueue(events: outboundEvents)
369 }
370
371 return MonitoringRunOutcome(
372 success: errors.count < results.count,
373 message: log.summary,
374 log: log
375 )
376 }
377
378 private func saveLog(_ log: MonitoringLog) {
379 var logs = MonitoringStorage.loadLogs()
380 logs.insert(log, at: 0)
381 MonitoringStorage.saveLogs(logs)
382 }
383
384 private func monitoringEvents(from log: MonitoringLog) -> [MonitoringEvent] {
385 var events: [MonitoringEvent] = log.checkedDomains.compactMap { result in
386 if let errorMessage = result.errorMessage {
387 return MonitoringEvent(
388 type: .monitoringFailure,
389 severity: .critical,
390 domain: result.domain,
391 timestamp: result.checkedAt,
392 summary: errorMessage,
393 details: [
394 "trigger": log.trigger.rawValue,
395 "resultSource": result.resultSource.rawValue
396 ]
397 )
398 }
399
400 if result.certificateWarningLevel == .critical {
401 return MonitoringEvent(
402 type: .certificateExpiring,
403 severity: .critical,
404 domain: result.domain,
405 timestamp: result.checkedAt,
406 summary: result.summaryMessage,
407 details: [
408 "certificateWarningLevel": result.certificateWarningLevel.rawValue,
409 "trigger": log.trigger.rawValue
410 ]
411 )
412 }
413
414 guard let alertSeverity = result.alertSeverity else {
415 return nil
416 }
417
418 return MonitoringEvent(
419 type: eventType(for: result.summaryMessage),
420 severity: EventSeverity(monitoringSeverity: alertSeverity),
421 domain: result.domain,
422 timestamp: result.checkedAt,
423 summary: result.summaryMessage,
424 details: [
425 "alertSeverity": alertSeverity.title,
426 "certificateWarningLevel": result.certificateWarningLevel.rawValue,
427 "resultSource": result.resultSource.rawValue,
428 "trigger": log.trigger.rawValue
429 ]
430 )
431 }
432
433 if !log.errors.isEmpty {
434 events.append(
435 MonitoringEvent(
436 type: .monitoringFailure,
437 severity: .critical,
438 domain: "portfolio",
439 timestamp: log.timestamp,
440 summary: "Monitoring run completed with errors",
441 details: [
442 "errors": log.errors.joined(separator: " | "),
443 "trigger": log.trigger.rawValue
444 ]
445 )
446 )
447 }
448
449 return events
450 }
451
452 private func eventType(for summary: String) -> MonitoringEventType {
453 let normalized = summary.lowercased()
454 if normalized.contains("dns") {
455 return .dnsChanged
456 }
457 if normalized.contains("certificate") {
458 return .certificateUpdated
459 }
460 if normalized.contains("redirect") {
461 return .redirectChanged
462 }
463 if normalized.contains("header") {
464 return .headersChanged
465 }
466 return .changeDetected
467 }
468
469 private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? {
470 history.first(where: { entry in
471 if let trackedDomainID = entry.trackedDomainID {
472 return trackedDomainID == trackedDomain.id
473 }
474 return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
475 })?.snapshot
476 }
477
478 private func persistSnapshot(
479 _ snapshot: LookupSnapshot,
480 trackedDomainID: UUID,
481 trackedDomains: inout [TrackedDomain],
482 history: inout [HistoryEntry]
483 ) -> HistoryEntry? {
484 let previousSnapshot = latestSnapshot(
485 for: trackedDomains.first(where: { $0.id == trackedDomainID }) ?? TrackedDomain(domain: snapshot.domain),
486 history: history
487 )
488 let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
489 let changeSummary = previousSnapshot.map {
490 DomainDiffService.summary(
491 from: $0,
492 to: snapshot,
493 generatedAt: snapshot.timestamp,
494 riskAssessment: analysis.riskAssessment,
495 insights: analysis.insights
496 )
497 }
498
499 let entry = HistoryEntry(
500 domain: snapshot.domain,
501 timestamp: snapshot.timestamp,
502 trackedDomainID: trackedDomainID,
503 note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note,
504 dnsSections: snapshot.dnsSections,
505 sslInfo: snapshot.sslInfo,
506 httpHeaders: snapshot.httpHeaders,
507 reachabilityResults: snapshot.reachabilityResults,
508 ipGeolocation: snapshot.ipGeolocation,
509 emailSecurity: snapshot.emailSecurity,
510 mtaSts: snapshot.emailSecurity?.mtaSts,
511 ownership: snapshot.ownership,
512 ownershipHistory: snapshot.ownershipHistory,
513 ptrRecord: snapshot.ptrRecord,
514 redirectChain: snapshot.redirectChain,
515 subdomains: snapshot.subdomains,
516 extendedSubdomains: snapshot.extendedSubdomains,
517 dnsHistory: snapshot.dnsHistory,
518 domainPricing: snapshot.domainPricing,
519 portScanResults: snapshot.portScanResults,
520 hstsPreloaded: snapshot.hstsPreloaded,
521 availabilityResult: snapshot.availabilityResult,
522 suggestions: snapshot.suggestions,
523 appVersion: snapshot.appVersion,
524 resultSource: snapshot.resultSource,
525 dataSources: snapshot.dataSources,
526 provenanceBySection: snapshot.provenanceBySection,
527 availabilityConfidence: snapshot.availabilityConfidence,
528 ownershipConfidence: snapshot.ownershipConfidence,
529 subdomainConfidence: snapshot.subdomainConfidence,
530 emailSecurityConfidence: snapshot.emailSecurityConfidence,
531 geolocationConfidence: snapshot.geolocationConfidence,
532 errorDetails: snapshot.errorDetails,
533 isPartialSnapshot: snapshot.isPartialSnapshot,
534 validationIssues: snapshot.validationIssues,
535 resolverDisplayName: snapshot.resolverDisplayName,
536 resolverURLString: snapshot.resolverURLString,
537 totalLookupDurationMs: snapshot.totalLookupDurationMs,
538 primaryIP: Self.primaryIPAddress(from: snapshot),
539 finalRedirectURL: snapshot.redirectChain.last?.url,
540 tlsStatusSummary: Self.tlsSummary(from: snapshot),
541 emailSecuritySummary: Self.emailSummary(from: snapshot),
542 httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
543 changeSummary: changeSummary,
544 sslError: snapshot.sslError,
545 httpHeadersError: snapshot.httpHeadersError,
546 reachabilityError: snapshot.reachabilityError,
547 ipGeolocationError: snapshot.ipGeolocationError,
548 emailSecurityError: snapshot.emailSecurityError,
549 ownershipError: snapshot.ownershipError,
550 ownershipHistoryError: snapshot.ownershipHistoryError,
551 ptrError: snapshot.ptrError,
552 redirectChainError: snapshot.redirectChainError,
553 subdomainsError: snapshot.subdomainsError,
554 extendedSubdomainsError: snapshot.extendedSubdomainsError,
555 dnsHistoryError: snapshot.dnsHistoryError,
556 domainPricingError: snapshot.domainPricingError,
557 portScanError: snapshot.portScanError
558 )
559
560 history.insert(entry, at: 0)
561 if history.count > maxHistoryEntries {
562 history = Array(history.prefix(maxHistoryEntries))
563 }
564
565 if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) {
566 trackedDomains[index].lastSnapshotID = entry.id
567 trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status
568 trackedDomains[index].updatedAt = snapshot.timestamp
569 trackedDomains[index].lastChangeSummary = changeSummary
570 trackedDomains[index].lastChangeSeverity = changeSummary?.severity
571 trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
572 trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry
573 }
574
575 return entry
576 }
577
578 private func shouldInspect(
579 trackedDomain: TrackedDomain,
580 trigger: MonitoringRunTrigger,
581 settings: MonitoringSettings
582 ) -> Bool {
583 guard trigger == .background else { return true }
584 guard let lastCheck = trackedDomain.monitoringState.lastCheck else { return true }
585 let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
586 return lastCheck.addingTimeInterval(state.currentInterval) <= Date()
587 }
588
589 private func processAdaptiveMonitoringState(
590 trackedDomain: TrackedDomain,
591 snapshot: LookupSnapshot,
592 previousSnapshot: LookupSnapshot?,
593 alertDescriptor: MonitoringAlertDescriptor?,
594 settings: MonitoringSettings,
595 notificationsAuthorized: Bool,
596 now: Date
597 ) async -> (trackedDomain: TrackedDomain, alertsTriggered: Int) {
598 var updated = trackedDomain
599 var state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
600 let baseInterval = settings.config.sanitizedBaseInterval
601 let minInterval = settings.sensitivity.minimumInterval
602 let maxInterval = settings.config.maxInterval
603 let isQuiet = settings.quietHours?.contains(now, calendar: calendar) == true
604 var alertsTriggered = 0
605
606 if let alertDescriptor {
607 if state.lastChangeHash != alertDescriptor.changeHash {
608 state.lastChangeHash = alertDescriptor.changeHash
609 state.lastChangeDate = now
610 state.consecutiveStableChecks = 0
611 state.currentInterval = settings.adaptiveEnabled ? minInterval : baseInterval
612
613 if settings.alertsEnabled,
614 alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
615 let pendingAlert = MonitoringPendingAlert(
616 detectedAt: now,
617 message: alertDescriptor.message,
618 severity: alertDescriptor.severity,
619 changeHash: alertDescriptor.changeHash
620 )
621
622 if isQuiet || !notificationsAuthorized {
623 updated.pendingMonitoringAlerts = deduplicatedPendingAlerts(
624 updated.pendingMonitoringAlerts + [pendingAlert]
625 )
626 } else {
627 let pendingAlerts = deduplicatedPendingAlerts(
628 updated.pendingMonitoringAlerts + [pendingAlert]
629 )
630 if pendingAlerts.count > 1 {
631 await LocalNotificationService.shared.notifyMonitoringSummary(
632 domain: trackedDomain.domain,
633 alerts: pendingAlerts
634 )
635 } else {
636 await LocalNotificationService.shared.notifyMonitoringAlert(
637 domain: trackedDomain.domain,
638 message: alertDescriptor.message,
639 severity: alertDescriptor.severity
640 )
641 }
642 alertsTriggered += 1
643 updated.pendingMonitoringAlerts = []
644 updated.lastAlertAt = now
645 state.lastAlertDate = now
646 }
647 }
648 }
649 } else {
650 state.consecutiveStableChecks += 1
651 if settings.adaptiveEnabled {
652 state.currentInterval = max(
653 baseInterval,
654 min(maxInterval, state.currentInterval * settings.sensitivity.intervalMultiplier)
655 )
656 } else {
657 state.currentInterval = baseInterval
658 }
659 }
660
661 if !isQuiet,
662 settings.alertsEnabled,
663 notificationsAuthorized,
664 !updated.pendingMonitoringAlerts.isEmpty,
665 alertDescriptor == nil {
666 await LocalNotificationService.shared.notifyMonitoringSummary(
667 domain: trackedDomain.domain,
668 alerts: updated.pendingMonitoringAlerts
669 )
670 alertsTriggered += 1
671 updated.pendingMonitoringAlerts = []
672 updated.lastAlertAt = now
673 state.lastAlertDate = now
674 }
675
676 state.lastCheck = now
677 updated.monitoringState = state
678 return (updated, alertsTriggered)
679 }
680
681 private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
682 var uniqueByHash: [String: MonitoringPendingAlert] = [:]
683 for alert in alerts {
684 if let existing = uniqueByHash[alert.changeHash] {
685 uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
686 } else {
687 uniqueByHash[alert.changeHash] = alert
688 }
689 }
690 return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
691 }
692
693 private struct MonitoringAlertDescriptor {
694 let severity: MonitoringAlertSeverity
695 let message: String
696 let changeHash: String
697 }
698
699 private struct HeaderFingerprint: Encodable, Equatable {
700 let name: String
701 let value: String
702 }
703
704 private func alertDescriptor(
705 previousSnapshot: LookupSnapshot?,
706 snapshot: LookupSnapshot,
707 entry: HistoryEntry?,
708 sensitivity: MonitoringSensitivity
709 ) -> MonitoringAlertDescriptor? {
710 guard let previousSnapshot else { return nil }
711 let previousHash = monitoringHash(for: previousSnapshot, sensitivity: sensitivity)
712 let currentHash = monitoringHash(for: snapshot, sensitivity: sensitivity)
713 guard previousHash != currentHash else { return nil }
714
715 let changedLabels = Set(
716 DomainDiffService.diff(from: previousSnapshot, to: snapshot)
717 .flatMap(\.items)
718 .filter(\.hasChanges)
719 .map(\.label)
720 )
721
722 if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
723 return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash)
724 }
725
726 let oldCertificateLevel = DomainDiffService.certificateWarningLevel(for: previousSnapshot)
727 let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
728 if snapshot.sslInfo?.issuer != previousSnapshot.sslInfo?.issuer
729 || snapshot.sslInfo?.validUntil != previousSnapshot.sslInfo?.validUntil
730 || (newCertificateLevel != .none && newCertificateLevel != oldCertificateLevel) {
731 return MonitoringAlertDescriptor(severity: .warning, message: "Certificate updated", changeHash: currentHash)
732 }
733
734 if sensitivity != .low,
735 previousSnapshot.redirectChain.map(\.url) != snapshot.redirectChain.map(\.url)
736 || previousSnapshot.redirectChain.map(\.statusCode) != snapshot.redirectChain.map(\.statusCode) {
737 return MonitoringAlertDescriptor(severity: .warning, message: "Redirect chain changed", changeHash: currentHash)
738 }
739
740 if sensitivity != .low,
741 filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity)
742 != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) {
743 return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash)
744 }
745
746 if sensitivity == .high,
747 entry?.changeSummary?.hasChanges == true,
748 let message = entry?.changeSummary?.message {
749 return MonitoringAlertDescriptor(severity: .info, message: message, changeHash: currentHash)
750 }
751
752 return nil
753 }
754
755 private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String {
756 struct DNSRecordPayload: Encodable {
757 let type: String
758 let records: [String]
759 let wildcardRecords: [String]
760 }
761
762 struct CertificatePayload: Encodable {
763 let issuer: String?
764 let commonName: String?
765 let validUntil: Date?
766 }
767
768 struct RedirectPayload: Encodable {
769 let url: String
770 let statusCode: Int
771 }
772
773 struct HeaderPayload: Encodable {
774 let name: String
775 let value: String
776 }
777
778 struct MonitoringHashPayload: Encodable {
779 let dns: [DNSRecordPayload]
780 let certificate: CertificatePayload
781 let headers: [HeaderPayload]
782 let redirects: [RedirectPayload]
783 }
784
785 let payload = MonitoringHashPayload(
786 dns: snapshot.dnsSections
787 .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
788 .map { section in
789 DNSRecordPayload(
790 type: section.recordType.rawValue,
791 records: section.records
792 .sorted { lhs, rhs in
793 lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
794 }
795 .map { "\($0.value)|\($0.ttl)" },
796 wildcardRecords: section.wildcardRecords
797 .sorted { lhs, rhs in
798 lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
799 }
800 .map { "\($0.value)|\($0.ttl)" }
801 )
802 },
803 certificate: CertificatePayload(
804 issuer: snapshot.sslInfo?.issuer,
805 commonName: snapshot.sslInfo?.commonName,
806 validUntil: snapshot.sslInfo?.validUntil
807 ),
808 headers: filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity).map {
809 HeaderPayload(name: $0.name, value: $0.value)
810 },
811 redirects: sensitivity == .low ? [] : snapshot.redirectChain.map {
812 RedirectPayload(url: $0.url, statusCode: $0.statusCode)
813 }
814 )
815
816 let encoder = JSONEncoder()
817 if #available(iOS 11.0, macOS 10.13, *) {
818 encoder.outputFormatting = [.sortedKeys]
819 }
820 let data = (try? encoder.encode(payload)) ?? Data()
821 return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
822 }
823
824 private func filteredHTTPHeaders(
825 _ headers: [HTTPHeader],
826 sensitivity: MonitoringSensitivity
827 ) -> [HeaderFingerprint] {
828 let mediumHeaderNames: Set<String> = [
829 "cache-control",
830 "content-security-policy",
831 "location",
832 "permissions-policy",
833 "referrer-policy",
834 "server",
835 "strict-transport-security",
836 "x-content-type-options",
837 "x-frame-options"
838 ]
839
840 return headers
841 .filter { header in
842 switch sensitivity {
843 case .low:
844 return false
845 case .medium:
846 return mediumHeaderNames.contains(header.name.lowercased())
847 case .high:
848 return true
849 }
850 }
851 .map { HeaderFingerprint(name: $0.name.lowercased(), value: $0.value) }
852 .sorted { lhs, rhs in
853 lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name
854 }
855 }
856
857 private static func resolvedSnapshotAfterFallback(
858 _ snapshot: LookupSnapshot,
859 previousSnapshot: LookupSnapshot?
860 ) -> LookupSnapshot {
861 guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
862 return snapshot
863 }
864
865 return LookupSnapshot(
866 historyEntryID: previousSnapshot.historyEntryID,
867 domain: previousSnapshot.domain,
868 timestamp: previousSnapshot.timestamp,
869 trackedDomainID: previousSnapshot.trackedDomainID,
870 note: previousSnapshot.note,
871 appVersion: previousSnapshot.appVersion,
872 resolverDisplayName: previousSnapshot.resolverDisplayName,
873 resolverURLString: previousSnapshot.resolverURLString,
874 dataSources: previousSnapshot.dataSources,
875 provenanceBySection: previousSnapshot.provenanceBySection,
876 availabilityConfidence: previousSnapshot.availabilityConfidence,
877 ownershipConfidence: previousSnapshot.ownershipConfidence,
878 subdomainConfidence: previousSnapshot.subdomainConfidence,
879 emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
880 geolocationConfidence: previousSnapshot.geolocationConfidence,
881 errorDetails: previousSnapshot.errorDetails,
882 isPartialSnapshot: previousSnapshot.isPartialSnapshot,
883 validationIssues: previousSnapshot.validationIssues,
884 totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
885 snapshotIndex: previousSnapshot.snapshotIndex,
886 previousSnapshotID: previousSnapshot.previousSnapshotID,
887 changeCount: previousSnapshot.changeCount,
888 severitySummary: previousSnapshot.severitySummary,
889 dnsSections: previousSnapshot.dnsSections,
890 dnsError: previousSnapshot.dnsError,
891 availabilityResult: previousSnapshot.availabilityResult,
892 suggestions: previousSnapshot.suggestions,
893 sslInfo: previousSnapshot.sslInfo,
894 sslError: previousSnapshot.sslError,
895 hstsPreloaded: previousSnapshot.hstsPreloaded,
896 httpHeaders: previousSnapshot.httpHeaders,
897 httpSecurityGrade: previousSnapshot.httpSecurityGrade,
898 httpStatusCode: previousSnapshot.httpStatusCode,
899 httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
900 httpProtocol: previousSnapshot.httpProtocol,
901 http3Advertised: previousSnapshot.http3Advertised,
902 httpHeadersError: previousSnapshot.httpHeadersError,
903 reachabilityResults: previousSnapshot.reachabilityResults,
904 reachabilityError: previousSnapshot.reachabilityError,
905 ipGeolocation: previousSnapshot.ipGeolocation,
906 ipGeolocationError: previousSnapshot.ipGeolocationError,
907 emailSecurity: previousSnapshot.emailSecurity,
908 emailSecurityError: previousSnapshot.emailSecurityError,
909 ownership: previousSnapshot.ownership,
910 ownershipError: previousSnapshot.ownershipError,
911 ownershipHistory: previousSnapshot.ownershipHistory,
912 ownershipHistoryError: previousSnapshot.ownershipHistoryError,
913 inferredProvider: previousSnapshot.inferredProvider,
914 priorProviders: previousSnapshot.priorProviders,
915 domainClassification: previousSnapshot.domainClassification,
916 ownershipTransitions: previousSnapshot.ownershipTransitions,
917 hostingTransitions: previousSnapshot.hostingTransitions,
918 subdomainHistory: previousSnapshot.subdomainHistory,
919 riskSignals: previousSnapshot.riskSignals,
920 intelligenceTimeline: previousSnapshot.intelligenceTimeline,
921 ptrRecord: previousSnapshot.ptrRecord,
922 ptrError: previousSnapshot.ptrError,
923 redirectChain: previousSnapshot.redirectChain,
924 redirectChainError: previousSnapshot.redirectChainError,
925 subdomains: previousSnapshot.subdomains,
926 subdomainsError: previousSnapshot.subdomainsError,
927 extendedSubdomains: previousSnapshot.extendedSubdomains,
928 extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
929 dnsHistory: previousSnapshot.dnsHistory,
930 dnsHistoryError: previousSnapshot.dnsHistoryError,
931 domainPricing: previousSnapshot.domainPricing,
932 domainPricingError: previousSnapshot.domainPricingError,
933 portScanResults: previousSnapshot.portScanResults,
934 portScanError: previousSnapshot.portScanError,
935 changeSummary: previousSnapshot.changeSummary,
936 resultSource: .snapshot,
937 cachedSections: [],
938 statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
939 )
940 }
941
942 private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
943 let candidateMessages = [
944 snapshot.dnsError,
945 snapshot.httpHeadersError,
946 snapshot.sslError,
947 snapshot.ownershipError,
948 snapshot.subdomainsError,
949 snapshot.redirectChainError,
950 snapshot.ipGeolocationError
951 ]
952 .compactMap { $0?.lowercased() }
953
954 guard !candidateMessages.isEmpty else { return false }
955 let connectivityFailure = candidateMessages.allSatisfy { message in
956 message.hasPrefix("network error:")
957 || message.hasPrefix("timeout:")
958 || message.hasPrefix("rate limit:")
959 }
960
961 let hasMaterialData = !snapshot.dnsSections.isEmpty
962 || !snapshot.httpHeaders.isEmpty
963 || snapshot.sslInfo != nil
964 || snapshot.ownership != nil
965 || !snapshot.subdomains.isEmpty
966
967 return connectivityFailure && !hasMaterialData
968 }
969
970 private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
971 snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
972 }
973
974 private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
975 guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError }
976 switch DomainDiffService.certificateWarningLevel(for: snapshot) {
977 case .critical:
978 return "Critical (\(sslInfo.daysUntilExpiry)d)"
979 case .warning:
980 return "Warning (\(sslInfo.daysUntilExpiry)d)"
981 case .none:
982 return "Healthy (\(sslInfo.daysUntilExpiry)d)"
983 }
984 }
985
986 private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
987 if let emailSecurity = snapshot.emailSecurity {
988 return [
989 "spf:\(emailSecurity.spf.found)",
990 "dmarc:\(emailSecurity.dmarc.found)",
991 "dkim:\(emailSecurity.dkim.found)",
992 "bimi:\(emailSecurity.bimi.found)",
993 "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
994 ].joined(separator: "|")
995 }
996 return snapshot.emailSecurityError
997 }
998}