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}