krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

v5.0.3: DomainDig/DomainMonitoringService.swift · raw

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