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}