krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.0: DomainDig/DomainMonitoringService.swift · raw

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