krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.7.0: DomainDig/DomainMonitoringService.swift · raw

   1import Foundation
   2import UserNotifications
   3import CryptoKit
   4
   5#if canImport(BackgroundTasks)
   6import BackgroundTasks
   7#endif
   8
   9#if canImport(UIKit)
  10import UIKit
  11#endif
  12
  13enum MonitoringStorage {
  14    static let settingsKey = "monitoring.settings"
  15    static let logsKey = "monitoring.logs"
  16    static let trackedDomainsKey = "trackedDomains"
  17    static let historyKey = "lookupHistory"
  18    static let maxLogs = 40
  19
  20    static func loadSettings() -> MonitoringSettings {
  21        DataMigrationService.migrateIfNeeded()
  22        return DomainDataPortabilityService.loadMonitoringSettings()
  23    }
  24
  25    static func saveSettings(_ settings: MonitoringSettings) {
  26        DomainDataPortabilityService.saveMonitoringSettings(settings)
  27    }
  28
  29    static func loadLogs() -> [MonitoringLog] {
  30        DataMigrationService.migrateIfNeeded()
  31        return DomainDataPortabilityService.loadMonitoringLogs()
  32    }
  33
  34    static func saveLogs(_ logs: [MonitoringLog]) {
  35        DomainDataPortabilityService.saveMonitoringLogs(Array(logs.prefix(maxLogs)))
  36    }
  37
  38    static func loadTrackedDomains() -> [TrackedDomain] {
  39        DataMigrationService.migrateIfNeeded()
  40        return DomainDataPortabilityService.loadTrackedDomains()
  41    }
  42
  43    static func saveTrackedDomains(_ domains: [TrackedDomain]) {
  44        DomainDataPortabilityService.saveTrackedDomains(domains)
  45    }
  46
  47    static func loadHistoryEntries() -> [HistoryEntry] {
  48        DataMigrationService.migrateIfNeeded()
  49        return DomainDataPortabilityService.loadHistoryEntries()
  50    }
  51
  52    static func saveHistoryEntries(_ entries: [HistoryEntry]) {
  53        DomainDataPortabilityService.saveHistoryEntries(entries)
  54    }
  55
  56    static func sanitizeSettings(_ settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> MonitoringSettings {
  57        let validIDs = Set(trackedDomains.map(\.id))
  58        var sanitized = settings
  59        sanitized.selectedDomainIDs = sanitized.selectedDomainIDs.filter { validIDs.contains($0) }
  60        return sanitized
  61    }
  62
  63    static func monitoredDomains(settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
  64        let sanitized = sanitizeSettings(settings, trackedDomains: trackedDomains)
  65        switch sanitized.scope {
  66        case .allTracked:
  67            return trackedDomains.filter(\.monitoringEnabled)
  68        case .selectedOnly:
  69            let selected = Set(sanitized.selectedDomainIDs)
  70            return trackedDomains.filter { selected.contains($0.id) && $0.monitoringEnabled }
  71        }
  72    }
  73}
  74
  75struct MonitoringRunOutcome {
  76    let success: Bool
  77    let message: String
  78    let log: MonitoringLog?
  79}
  80
  81@MainActor
  82final class DomainMonitoringScheduler {
  83    static let shared = DomainMonitoringScheduler()
  84    static let taskIdentifier = "net.cleberg.DomainDig.monitor.refresh"
  85
  86    private var isRegistered = false
  87
  88    private init() {}
  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            reputation: snapshot.reputation,
 520            portScanResults: snapshot.portScanResults,
 521            hstsPreloaded: snapshot.hstsPreloaded,
 522            availabilityResult: snapshot.availabilityResult,
 523            suggestions: snapshot.suggestions,
 524            appVersion: snapshot.appVersion,
 525            resultSource: snapshot.resultSource,
 526            dataSources: snapshot.dataSources,
 527            provenanceBySection: snapshot.provenanceBySection,
 528            availabilityConfidence: snapshot.availabilityConfidence,
 529            ownershipConfidence: snapshot.ownershipConfidence,
 530            subdomainConfidence: snapshot.subdomainConfidence,
 531            emailSecurityConfidence: snapshot.emailSecurityConfidence,
 532            geolocationConfidence: snapshot.geolocationConfidence,
 533            errorDetails: snapshot.errorDetails,
 534            isPartialSnapshot: snapshot.isPartialSnapshot,
 535            validationIssues: snapshot.validationIssues,
 536            resolverDisplayName: snapshot.resolverDisplayName,
 537            resolverURLString: snapshot.resolverURLString,
 538            totalLookupDurationMs: snapshot.totalLookupDurationMs,
 539            primaryIP: Self.primaryIPAddress(from: snapshot),
 540            finalRedirectURL: snapshot.redirectChain.last?.url,
 541            tlsStatusSummary: Self.tlsSummary(from: snapshot),
 542            emailSecuritySummary: Self.emailSummary(from: snapshot),
 543            httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
 544            changeSummary: changeSummary,
 545            sslError: snapshot.sslError,
 546            httpHeadersError: snapshot.httpHeadersError,
 547            reachabilityError: snapshot.reachabilityError,
 548            ipGeolocationError: snapshot.ipGeolocationError,
 549            emailSecurityError: snapshot.emailSecurityError,
 550            ownershipError: snapshot.ownershipError,
 551            ownershipHistoryError: snapshot.ownershipHistoryError,
 552            ptrError: snapshot.ptrError,
 553            redirectChainError: snapshot.redirectChainError,
 554            subdomainsError: snapshot.subdomainsError,
 555            extendedSubdomainsError: snapshot.extendedSubdomainsError,
 556            dnsHistoryError: snapshot.dnsHistoryError,
 557            domainPricingError: snapshot.domainPricingError,
 558            reputationError: snapshot.reputationError,
 559            portScanError: snapshot.portScanError
 560        )
 561
 562        history.insert(entry, at: 0)
 563        if history.count > maxHistoryEntries {
 564            history = Array(history.prefix(maxHistoryEntries))
 565        }
 566
 567        if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) {
 568            trackedDomains[index].lastSnapshotID = entry.id
 569            trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status
 570            trackedDomains[index].updatedAt = snapshot.timestamp
 571            trackedDomains[index].lastChangeSummary = changeSummary
 572            trackedDomains[index].lastChangeSeverity = changeSummary?.severity
 573            trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
 574            trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry
 575        }
 576
 577        return entry
 578    }
 579
 580    private func shouldInspect(
 581        trackedDomain: TrackedDomain,
 582        trigger: MonitoringRunTrigger,
 583        settings: MonitoringSettings
 584    ) -> Bool {
 585        guard trigger == .background else { return true }
 586        guard let lastCheck = trackedDomain.monitoringState.lastCheck else { return true }
 587        let state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
 588        return lastCheck.addingTimeInterval(state.currentInterval) <= Date()
 589    }
 590
 591    private func processAdaptiveMonitoringState(
 592        trackedDomain: TrackedDomain,
 593        snapshot: LookupSnapshot,
 594        previousSnapshot: LookupSnapshot?,
 595        alertDescriptor: MonitoringAlertDescriptor?,
 596        settings: MonitoringSettings,
 597        notificationsAuthorized: Bool,
 598        now: Date
 599    ) async -> (trackedDomain: TrackedDomain, alertsTriggered: Int) {
 600        var updated = trackedDomain
 601        var state = DomainMonitoringScheduler.normalizedMonitoringState(for: trackedDomain, settings: settings)
 602        let baseInterval = settings.config.sanitizedBaseInterval
 603        let minInterval = settings.sensitivity.minimumInterval
 604        let maxInterval = settings.config.maxInterval
 605        let isQuiet = settings.quietHours?.contains(now, calendar: calendar) == true
 606        var alertsTriggered = 0
 607
 608        if let alertDescriptor {
 609            if state.lastChangeHash != alertDescriptor.changeHash {
 610                state.lastChangeHash = alertDescriptor.changeHash
 611                state.lastChangeDate = now
 612                state.consecutiveStableChecks = 0
 613                state.currentInterval = settings.adaptiveEnabled ? minInterval : baseInterval
 614
 615                if settings.alertsEnabled,
 616                   alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
 617                    let pendingAlert = MonitoringPendingAlert(
 618                        detectedAt: now,
 619                        message: alertDescriptor.message,
 620                        severity: alertDescriptor.severity,
 621                        changeHash: alertDescriptor.changeHash
 622                    )
 623
 624                    if isQuiet || !notificationsAuthorized {
 625                        updated.pendingMonitoringAlerts = deduplicatedPendingAlerts(
 626                            updated.pendingMonitoringAlerts + [pendingAlert]
 627                        )
 628                    } else {
 629                        let pendingAlerts = deduplicatedPendingAlerts(
 630                            updated.pendingMonitoringAlerts + [pendingAlert]
 631                        )
 632                        if pendingAlerts.count > 1 {
 633                            await LocalNotificationService.shared.notifyMonitoringSummary(
 634                                domain: trackedDomain.domain,
 635                                alerts: pendingAlerts
 636                            )
 637                        } else {
 638                            await LocalNotificationService.shared.notifyMonitoringAlert(
 639                                domain: trackedDomain.domain,
 640                                message: alertDescriptor.message,
 641                                severity: alertDescriptor.severity
 642                            )
 643                        }
 644                        alertsTriggered += 1
 645                        updated.pendingMonitoringAlerts = []
 646                        updated.lastAlertAt = now
 647                        state.lastAlertDate = now
 648                    }
 649                }
 650            }
 651        } else {
 652            state.consecutiveStableChecks += 1
 653            if settings.adaptiveEnabled {
 654                state.currentInterval = max(
 655                    baseInterval,
 656                    min(maxInterval, state.currentInterval * settings.sensitivity.intervalMultiplier)
 657                )
 658            } else {
 659                state.currentInterval = baseInterval
 660            }
 661        }
 662
 663        if !isQuiet,
 664           settings.alertsEnabled,
 665           notificationsAuthorized,
 666           !updated.pendingMonitoringAlerts.isEmpty,
 667           alertDescriptor == nil {
 668            await LocalNotificationService.shared.notifyMonitoringSummary(
 669                domain: trackedDomain.domain,
 670                alerts: updated.pendingMonitoringAlerts
 671            )
 672            alertsTriggered += 1
 673            updated.pendingMonitoringAlerts = []
 674            updated.lastAlertAt = now
 675            state.lastAlertDate = now
 676        }
 677
 678        state.lastCheck = now
 679        updated.monitoringState = state
 680        return (updated, alertsTriggered)
 681    }
 682
 683    private func deduplicatedPendingAlerts(_ alerts: [MonitoringPendingAlert]) -> [MonitoringPendingAlert] {
 684        var uniqueByHash: [String: MonitoringPendingAlert] = [:]
 685        for alert in alerts {
 686            if let existing = uniqueByHash[alert.changeHash] {
 687                uniqueByHash[alert.changeHash] = existing.detectedAt >= alert.detectedAt ? existing : alert
 688            } else {
 689                uniqueByHash[alert.changeHash] = alert
 690            }
 691        }
 692        return uniqueByHash.values.sorted { $0.detectedAt < $1.detectedAt }
 693    }
 694
 695    private struct MonitoringAlertDescriptor {
 696        let severity: MonitoringAlertSeverity
 697        let message: String
 698        let changeHash: String
 699    }
 700
 701    private struct HeaderFingerprint: Encodable, Equatable {
 702        let name: String
 703        let value: String
 704    }
 705
 706    private func alertDescriptor(
 707        previousSnapshot: LookupSnapshot?,
 708        snapshot: LookupSnapshot,
 709        entry: HistoryEntry?,
 710        sensitivity: MonitoringSensitivity
 711    ) -> MonitoringAlertDescriptor? {
 712        guard let previousSnapshot else { return nil }
 713        let previousHash = monitoringHash(for: previousSnapshot, sensitivity: sensitivity)
 714        let currentHash = monitoringHash(for: snapshot, sensitivity: sensitivity)
 715        guard previousHash != currentHash else { return nil }
 716
 717        let changedLabels = Set(
 718            DomainDiffService.diff(from: previousSnapshot, to: snapshot)
 719                .flatMap(\.items)
 720                .filter(\.hasChanges)
 721                .map(\.label)
 722        )
 723
 724        if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
 725            return MonitoringAlertDescriptor(severity: .warning, message: "DNS records changed", changeHash: currentHash)
 726        }
 727
 728        let oldCertificateLevel = DomainDiffService.certificateWarningLevel(for: previousSnapshot)
 729        let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
 730        if snapshot.sslInfo?.issuer != previousSnapshot.sslInfo?.issuer
 731            || snapshot.sslInfo?.validUntil != previousSnapshot.sslInfo?.validUntil
 732            || (newCertificateLevel != .none && newCertificateLevel != oldCertificateLevel) {
 733            return MonitoringAlertDescriptor(severity: .warning, message: "Certificate updated", changeHash: currentHash)
 734        }
 735
 736        if sensitivity != .low,
 737           previousSnapshot.redirectChain.map(\.url) != snapshot.redirectChain.map(\.url)
 738            || previousSnapshot.redirectChain.map(\.statusCode) != snapshot.redirectChain.map(\.statusCode) {
 739            return MonitoringAlertDescriptor(severity: .warning, message: "Redirect chain changed", changeHash: currentHash)
 740        }
 741
 742        if sensitivity != .low,
 743           filteredHTTPHeaders(previousSnapshot.httpHeaders, sensitivity: sensitivity)
 744            != filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity) {
 745            return MonitoringAlertDescriptor(severity: .info, message: "HTTP headers changed", changeHash: currentHash)
 746        }
 747
 748        if sensitivity == .high,
 749           entry?.changeSummary?.hasChanges == true,
 750           let message = entry?.changeSummary?.message {
 751            return MonitoringAlertDescriptor(severity: .info, message: message, changeHash: currentHash)
 752        }
 753
 754        return nil
 755    }
 756
 757    private func monitoringHash(for snapshot: LookupSnapshot, sensitivity: MonitoringSensitivity) -> String {
 758        struct DNSRecordPayload: Encodable {
 759            let type: String
 760            let records: [String]
 761            let wildcardRecords: [String]
 762        }
 763
 764        struct CertificatePayload: Encodable {
 765            let issuer: String?
 766            let commonName: String?
 767            let validUntil: Date?
 768        }
 769
 770        struct RedirectPayload: Encodable {
 771            let url: String
 772            let statusCode: Int
 773        }
 774
 775        struct HeaderPayload: Encodable {
 776            let name: String
 777            let value: String
 778        }
 779
 780        struct MonitoringHashPayload: Encodable {
 781            let dns: [DNSRecordPayload]
 782            let certificate: CertificatePayload
 783            let headers: [HeaderPayload]
 784            let redirects: [RedirectPayload]
 785        }
 786
 787        let payload = MonitoringHashPayload(
 788            dns: snapshot.dnsSections
 789                .sorted { $0.recordType.rawValue < $1.recordType.rawValue }
 790                .map { section in
 791                    DNSRecordPayload(
 792                        type: section.recordType.rawValue,
 793                        records: section.records
 794                            .sorted { lhs, rhs in
 795                                lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
 796                            }
 797                            .map { "\($0.value)|\($0.ttl)" },
 798                        wildcardRecords: section.wildcardRecords
 799                            .sorted { lhs, rhs in
 800                                lhs.value == rhs.value ? lhs.ttl < rhs.ttl : lhs.value < rhs.value
 801                            }
 802                            .map { "\($0.value)|\($0.ttl)" }
 803                    )
 804                },
 805            certificate: CertificatePayload(
 806                issuer: snapshot.sslInfo?.issuer,
 807                commonName: snapshot.sslInfo?.commonName,
 808                validUntil: snapshot.sslInfo?.validUntil
 809            ),
 810            headers: filteredHTTPHeaders(snapshot.httpHeaders, sensitivity: sensitivity).map {
 811                HeaderPayload(name: $0.name, value: $0.value)
 812            },
 813            redirects: sensitivity == .low ? [] : snapshot.redirectChain.map {
 814                RedirectPayload(url: $0.url, statusCode: $0.statusCode)
 815            }
 816        )
 817
 818        let encoder = JSONEncoder()
 819        if #available(iOS 11.0, macOS 10.13, *) {
 820            encoder.outputFormatting = [.sortedKeys]
 821        }
 822        let data = (try? encoder.encode(payload)) ?? Data()
 823        return SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined()
 824    }
 825
 826    private func filteredHTTPHeaders(
 827        _ headers: [HTTPHeader],
 828        sensitivity: MonitoringSensitivity
 829    ) -> [HeaderFingerprint] {
 830        let mediumHeaderNames: Set<String> = [
 831            "cache-control",
 832            "content-security-policy",
 833            "location",
 834            "permissions-policy",
 835            "referrer-policy",
 836            "server",
 837            "strict-transport-security",
 838            "x-content-type-options",
 839            "x-frame-options"
 840        ]
 841
 842        return headers
 843            .filter { header in
 844                switch sensitivity {
 845                case .low:
 846                    return false
 847                case .medium:
 848                    return mediumHeaderNames.contains(header.name.lowercased())
 849                case .high:
 850                    return true
 851                }
 852            }
 853            .map { HeaderFingerprint(name: $0.name.lowercased(), value: $0.value) }
 854            .sorted { lhs, rhs in
 855                lhs.name == rhs.name ? lhs.value < rhs.value : lhs.name < rhs.name
 856            }
 857    }
 858
 859    private static func resolvedSnapshotAfterFallback(
 860        _ snapshot: LookupSnapshot,
 861        previousSnapshot: LookupSnapshot?
 862    ) -> LookupSnapshot {
 863        guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
 864            return snapshot
 865        }
 866
 867        return LookupSnapshot(
 868            historyEntryID: previousSnapshot.historyEntryID,
 869            domain: previousSnapshot.domain,
 870            timestamp: previousSnapshot.timestamp,
 871            trackedDomainID: previousSnapshot.trackedDomainID,
 872            note: previousSnapshot.note,
 873            appVersion: previousSnapshot.appVersion,
 874            resolverDisplayName: previousSnapshot.resolverDisplayName,
 875            resolverURLString: previousSnapshot.resolverURLString,
 876            dataSources: previousSnapshot.dataSources,
 877            provenanceBySection: previousSnapshot.provenanceBySection,
 878            availabilityConfidence: previousSnapshot.availabilityConfidence,
 879            ownershipConfidence: previousSnapshot.ownershipConfidence,
 880            subdomainConfidence: previousSnapshot.subdomainConfidence,
 881            emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
 882            geolocationConfidence: previousSnapshot.geolocationConfidence,
 883            errorDetails: previousSnapshot.errorDetails,
 884            isPartialSnapshot: previousSnapshot.isPartialSnapshot,
 885            validationIssues: previousSnapshot.validationIssues,
 886            totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
 887            snapshotIndex: previousSnapshot.snapshotIndex,
 888            previousSnapshotID: previousSnapshot.previousSnapshotID,
 889            changeCount: previousSnapshot.changeCount,
 890            severitySummary: previousSnapshot.severitySummary,
 891            dnsSections: previousSnapshot.dnsSections,
 892            dnsError: previousSnapshot.dnsError,
 893            availabilityResult: previousSnapshot.availabilityResult,
 894            suggestions: previousSnapshot.suggestions,
 895            sslInfo: previousSnapshot.sslInfo,
 896            sslError: previousSnapshot.sslError,
 897            hstsPreloaded: previousSnapshot.hstsPreloaded,
 898            httpHeaders: previousSnapshot.httpHeaders,
 899            httpSecurityGrade: previousSnapshot.httpSecurityGrade,
 900            httpStatusCode: previousSnapshot.httpStatusCode,
 901            httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
 902            httpProtocol: previousSnapshot.httpProtocol,
 903            http3Advertised: previousSnapshot.http3Advertised,
 904            httpHeadersError: previousSnapshot.httpHeadersError,
 905            reachabilityResults: previousSnapshot.reachabilityResults,
 906            reachabilityError: previousSnapshot.reachabilityError,
 907            ipGeolocation: previousSnapshot.ipGeolocation,
 908            ipGeolocationError: previousSnapshot.ipGeolocationError,
 909            emailSecurity: previousSnapshot.emailSecurity,
 910            emailSecurityError: previousSnapshot.emailSecurityError,
 911            ownership: previousSnapshot.ownership,
 912            ownershipError: previousSnapshot.ownershipError,
 913            ownershipHistory: previousSnapshot.ownershipHistory,
 914            ownershipHistoryError: previousSnapshot.ownershipHistoryError,
 915            inferredProvider: previousSnapshot.inferredProvider,
 916            priorProviders: previousSnapshot.priorProviders,
 917            domainClassification: previousSnapshot.domainClassification,
 918            ownershipTransitions: previousSnapshot.ownershipTransitions,
 919            hostingTransitions: previousSnapshot.hostingTransitions,
 920            subdomainHistory: previousSnapshot.subdomainHistory,
 921            riskSignals: previousSnapshot.riskSignals,
 922            intelligenceTimeline: previousSnapshot.intelligenceTimeline,
 923            ptrRecord: previousSnapshot.ptrRecord,
 924            ptrError: previousSnapshot.ptrError,
 925            redirectChain: previousSnapshot.redirectChain,
 926            redirectChainError: previousSnapshot.redirectChainError,
 927            subdomains: previousSnapshot.subdomains,
 928            subdomainsError: previousSnapshot.subdomainsError,
 929            extendedSubdomains: previousSnapshot.extendedSubdomains,
 930            extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
 931            dnsHistory: previousSnapshot.dnsHistory,
 932            dnsHistoryError: previousSnapshot.dnsHistoryError,
 933            domainPricing: previousSnapshot.domainPricing,
 934            domainPricingError: previousSnapshot.domainPricingError,
 935            reputation: previousSnapshot.reputation,
 936            reputationError: previousSnapshot.reputationError,
 937            portScanResults: previousSnapshot.portScanResults,
 938            portScanError: previousSnapshot.portScanError,
 939            changeSummary: previousSnapshot.changeSummary,
 940            resultSource: .snapshot,
 941            cachedSections: [],
 942            statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
 943        )
 944    }
 945
 946    private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
 947        let candidateMessages = [
 948            snapshot.dnsError,
 949            snapshot.httpHeadersError,
 950            snapshot.sslError,
 951            snapshot.ownershipError,
 952            snapshot.subdomainsError,
 953            snapshot.redirectChainError,
 954            snapshot.ipGeolocationError
 955        ]
 956        .compactMap { $0?.lowercased() }
 957
 958        guard !candidateMessages.isEmpty else { return false }
 959        let connectivityFailure = candidateMessages.allSatisfy { message in
 960            message.hasPrefix("network error:")
 961                || message.hasPrefix("timeout:")
 962                || message.hasPrefix("rate limit:")
 963        }
 964
 965        let hasMaterialData = !snapshot.dnsSections.isEmpty
 966            || !snapshot.httpHeaders.isEmpty
 967            || snapshot.sslInfo != nil
 968            || snapshot.ownership != nil
 969            || !snapshot.subdomains.isEmpty
 970
 971        return connectivityFailure && !hasMaterialData
 972    }
 973
 974    private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
 975        snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
 976    }
 977
 978    private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
 979        guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError }
 980        switch DomainDiffService.certificateWarningLevel(for: snapshot) {
 981        case .critical:
 982            return "Critical (\(sslInfo.daysUntilExpiry)d)"
 983        case .warning:
 984            return "Warning (\(sslInfo.daysUntilExpiry)d)"
 985        case .none:
 986            return "Healthy (\(sslInfo.daysUntilExpiry)d)"
 987        }
 988    }
 989
 990    private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
 991        if let emailSecurity = snapshot.emailSecurity {
 992            return [
 993                "spf:\(emailSecurity.spf.found)",
 994                "dmarc:\(emailSecurity.dmarc.found)",
 995                "dkim:\(emailSecurity.dkim.found)",
 996                "bimi:\(emailSecurity.bimi.found)",
 997                "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
 998            ].joined(separator: "|")
 999        }
1000        return snapshot.emailSecurityError
1001    }
1002}