summaryrefslogtreecommitdiff
path: root/DomainDig/DomainMonitoringService.swift
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig/DomainMonitoringService.swift')
-rw-r--r--DomainDig/DomainMonitoringService.swift613
1 files changed, 613 insertions, 0 deletions
diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
new file mode 100644
index 0000000..5128c0b
--- /dev/null
+++ b/DomainDig/DomainMonitoringService.swift
@@ -0,0 +1,613 @@
+import Foundation
+import UserNotifications
+
+#if canImport(BackgroundTasks)
+import BackgroundTasks
+#endif
+
+#if canImport(UIKit)
+import UIKit
+#endif
+
+enum MonitoringStorage {
+ static let settingsKey = "monitoring.settings"
+ static let logsKey = "monitoring.logs"
+ static let trackedDomainsKey = "trackedDomains"
+ static let historyKey = "lookupHistory"
+ static let maxLogs = 40
+
+ static func loadSettings() -> MonitoringSettings {
+ DataMigrationService.migrateIfNeeded()
+ return DomainDataPortabilityService.loadMonitoringSettings()
+ }
+
+ static func saveSettings(_ settings: MonitoringSettings) {
+ DomainDataPortabilityService.saveMonitoringSettings(settings)
+ }
+
+ static func loadLogs() -> [MonitoringLog] {
+ DataMigrationService.migrateIfNeeded()
+ return DomainDataPortabilityService.loadMonitoringLogs()
+ }
+
+ static func saveLogs(_ logs: [MonitoringLog]) {
+ DomainDataPortabilityService.saveMonitoringLogs(Array(logs.prefix(maxLogs)))
+ }
+
+ static func loadTrackedDomains() -> [TrackedDomain] {
+ DataMigrationService.migrateIfNeeded()
+ return DomainDataPortabilityService.loadTrackedDomains()
+ }
+
+ static func saveTrackedDomains(_ domains: [TrackedDomain]) {
+ DomainDataPortabilityService.saveTrackedDomains(domains)
+ }
+
+ static func loadHistoryEntries() -> [HistoryEntry] {
+ DataMigrationService.migrateIfNeeded()
+ return DomainDataPortabilityService.loadHistoryEntries()
+ }
+
+ static func saveHistoryEntries(_ entries: [HistoryEntry]) {
+ DomainDataPortabilityService.saveHistoryEntries(entries)
+ }
+
+ static func sanitizeSettings(_ settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> MonitoringSettings {
+ let validIDs = Set(trackedDomains.map(\.id))
+ var sanitized = settings
+ sanitized.selectedDomainIDs = sanitized.selectedDomainIDs.filter { validIDs.contains($0) }
+ return sanitized
+ }
+
+ static func monitoredDomains(settings: MonitoringSettings, trackedDomains: [TrackedDomain]) -> [TrackedDomain] {
+ let sanitized = sanitizeSettings(settings, trackedDomains: trackedDomains)
+ switch sanitized.scope {
+ case .allTracked:
+ return trackedDomains.filter(\.monitoringEnabled)
+ case .selectedOnly:
+ let selected = Set(sanitized.selectedDomainIDs)
+ return trackedDomains.filter { selected.contains($0.id) && $0.monitoringEnabled }
+ }
+ }
+}
+
+struct MonitoringRunOutcome {
+ let success: Bool
+ let message: String
+ let log: MonitoringLog?
+}
+
+@MainActor
+final class DomainMonitoringScheduler {
+ static let shared = DomainMonitoringScheduler()
+ static let taskIdentifier = "net.cleberg.DomainDig.monitor.refresh"
+
+ private var isRegistered = false
+
+ private init() {}
+
+ func registerBackgroundTask() {
+ #if canImport(BackgroundTasks)
+ guard !isRegistered else { return }
+ isRegistered = BGTaskScheduler.shared.register(forTaskWithIdentifier: Self.taskIdentifier, using: nil) { task in
+ guard let refreshTask = task as? BGAppRefreshTask else {
+ task.setTaskCompleted(success: false)
+ return
+ }
+ self.handleAppRefresh(task: refreshTask)
+ }
+ #endif
+ }
+
+ func backgroundRefreshStatusDescription() -> String {
+ #if canImport(UIKit)
+ switch UIApplication.shared.backgroundRefreshStatus {
+ case .available:
+ return "Available"
+ case .denied:
+ return "Disabled in Settings"
+ case .restricted:
+ return "Restricted by the system"
+ @unknown default:
+ return "Unknown"
+ }
+ #else
+ return "Unavailable on this platform"
+ #endif
+ }
+
+ @discardableResult
+ func syncSchedule() -> String? {
+ #if canImport(BackgroundTasks)
+ let settings = MonitoringStorage.loadSettings()
+ guard settings.isEnabled, FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+ return nil
+ }
+
+ #if canImport(UIKit)
+ guard UIApplication.shared.backgroundRefreshStatus == .available else {
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+ return "Background refresh is unavailable."
+ }
+ #endif
+
+ let request = BGAppRefreshTaskRequest(identifier: Self.taskIdentifier)
+ request.earliestBeginDate = Date(
+ timeIntervalSinceNow: max(settings.frequency.schedulingInterval, 15 * 60)
+ )
+
+ do {
+ BGTaskScheduler.shared.cancel(taskRequestWithIdentifier: Self.taskIdentifier)
+ try BGTaskScheduler.shared.submit(request)
+ return nil
+ } catch {
+ return "Could not schedule monitoring."
+ }
+ #else
+ return "Background monitoring is unavailable on this platform."
+ #endif
+ }
+
+#if canImport(BackgroundTasks)
+ private func handleAppRefresh(task: BGAppRefreshTask) {
+ _ = syncSchedule()
+
+ let worker = Task {
+ let outcome = await DomainMonitoringService.shared.performMonitoring(
+ trigger: .background,
+ requireEnabledSetting: true
+ )
+ task.setTaskCompleted(success: outcome.success)
+ }
+
+ task.expirationHandler = {
+ worker.cancel()
+ }
+ }
+#endif
+}
+
+@MainActor
+final class DomainMonitoringService {
+ static let shared = DomainMonitoringService()
+
+ private let inspectionService = DomainInspectionService()
+ private let maxHistoryEntries = 250
+
+ func performMonitoring(
+ trigger: MonitoringRunTrigger,
+ requireEnabledSetting: Bool
+ ) async -> MonitoringRunOutcome {
+ let trackedDomains = MonitoringStorage.loadTrackedDomains()
+ var settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains)
+ MonitoringStorage.saveSettings(settings)
+
+ guard FeatureAccessService.hasAccess(to: .automatedMonitoring) else {
+ if settings.isEnabled {
+ settings.isEnabled = false
+ MonitoringStorage.saveSettings(settings)
+ await MainActor.run {
+ _ = DomainMonitoringScheduler.shared.syncSchedule()
+ }
+ }
+ return MonitoringRunOutcome(success: false, message: "Monitoring requires Pro.", log: nil)
+ }
+
+ if requireEnabledSetting, !settings.isEnabled {
+ return MonitoringRunOutcome(success: false, message: "Monitoring is disabled.", log: nil)
+ }
+
+ var history = MonitoringStorage.loadHistoryEntries()
+ var mutableTrackedDomains = trackedDomains
+ let eligibleDomains = MonitoringStorage.monitoredDomains(settings: settings, trackedDomains: mutableTrackedDomains)
+
+ guard !eligibleDomains.isEmpty else {
+ let log = MonitoringLog(
+ timestamp: Date(),
+ trigger: trigger,
+ domainsChecked: 0,
+ changesFound: 0,
+ alertsTriggered: 0,
+ checkedDomains: [],
+ errors: ["No tracked domains are configured for monitoring."]
+ )
+ saveLog(log)
+ return MonitoringRunOutcome(success: false, message: "No domains selected for monitoring.", log: log)
+ }
+
+ let notificationsAuthorized: Bool
+ if settings.alertsEnabled {
+ notificationsAuthorized = await LocalNotificationService.shared.isAuthorizedForAlerts()
+ } else {
+ notificationsAuthorized = false
+ }
+ var results: [MonitoringDomainResult] = []
+ var errors: [String] = []
+ var alertsTriggered = 0
+
+ for trackedDomain in eligibleDomains {
+ guard !Task.isCancelled else {
+ return MonitoringRunOutcome(success: false, message: "Monitoring cancelled.", log: nil)
+ }
+
+ let previousSnapshot = latestSnapshot(for: trackedDomain, history: history)
+ let inspectedSnapshot = await inspectionService.inspectSnapshot(
+ domain: trackedDomain.domain,
+ previousSnapshot: previousSnapshot
+ )
+ let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot)
+ let savedEntry: HistoryEntry?
+ if snapshot.statusMessage == nil {
+ savedEntry = persistSnapshot(
+ snapshot,
+ trackedDomainID: trackedDomain.id,
+ trackedDomains: &mutableTrackedDomains,
+ history: &history
+ )
+ } else {
+ savedEntry = history.first(where: { $0.id == snapshot.historyEntryID })
+ }
+
+ let alertDescriptor = alertDescriptor(
+ previousSnapshot: previousSnapshot,
+ snapshot: snapshot,
+ entry: savedEntry
+ )
+
+ if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
+ mutableTrackedDomains[index].lastMonitoredAt = Date()
+ }
+
+ if notificationsAuthorized,
+ let alertDescriptor,
+ alertDescriptor.severity >= settings.alertFilter.minimumSeverity {
+ await LocalNotificationService.shared.notifyMonitoringAlert(
+ domain: trackedDomain.domain,
+ message: alertDescriptor.message,
+ severity: alertDescriptor.severity
+ )
+ alertsTriggered += 1
+ if let index = mutableTrackedDomains.firstIndex(where: { $0.id == trackedDomain.id }) {
+ mutableTrackedDomains[index].lastAlertAt = Date()
+ }
+ }
+
+ let result = MonitoringDomainResult(
+ domain: trackedDomain.domain,
+ historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
+ checkedAt: Date(),
+ didChange: snapshot.statusMessage == nil && savedEntry?.changeSummary?.hasChanges == true,
+ summaryMessage: snapshot.statusMessage
+ ?? savedEntry?.changeSummary?.message
+ ?? "No meaningful changes",
+ alertSeverity: alertDescriptor?.severity,
+ certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
+ resultSource: snapshot.resultSource,
+ errorMessage: snapshot.statusMessage
+ )
+ results.append(result)
+
+ if let errorMessage = result.errorMessage {
+ errors.append("\(trackedDomain.domain): \(errorMessage)")
+ }
+ }
+
+ MonitoringStorage.saveTrackedDomains(mutableTrackedDomains)
+ MonitoringStorage.saveHistoryEntries(history)
+
+ let log = MonitoringLog(
+ timestamp: Date(),
+ trigger: trigger,
+ domainsChecked: results.count,
+ changesFound: results.filter(\.didChange).count,
+ alertsTriggered: alertsTriggered,
+ checkedDomains: results.sorted {
+ $0.domain.localizedCaseInsensitiveCompare($1.domain) == .orderedAscending
+ },
+ errors: errors
+ )
+ saveLog(log)
+
+ return MonitoringRunOutcome(
+ success: errors.count < results.count,
+ message: log.summary,
+ log: log
+ )
+ }
+
+ private func saveLog(_ log: MonitoringLog) {
+ var logs = MonitoringStorage.loadLogs()
+ logs.insert(log, at: 0)
+ MonitoringStorage.saveLogs(logs)
+ }
+
+ private func latestSnapshot(for trackedDomain: TrackedDomain, history: [HistoryEntry]) -> LookupSnapshot? {
+ history.first(where: { entry in
+ if let trackedDomainID = entry.trackedDomainID {
+ return trackedDomainID == trackedDomain.id
+ }
+ return entry.domain.caseInsensitiveCompare(trackedDomain.domain) == .orderedSame
+ })?.snapshot
+ }
+
+ private func persistSnapshot(
+ _ snapshot: LookupSnapshot,
+ trackedDomainID: UUID,
+ trackedDomains: inout [TrackedDomain],
+ history: inout [HistoryEntry]
+ ) -> HistoryEntry? {
+ let previousSnapshot = latestSnapshot(
+ for: trackedDomains.first(where: { $0.id == trackedDomainID }) ?? TrackedDomain(domain: snapshot.domain),
+ history: history
+ )
+ let analysis = DomainInsightEngine.analyze(snapshot: snapshot, previousSnapshot: previousSnapshot)
+ let changeSummary = previousSnapshot.map {
+ DomainDiffService.summary(
+ from: $0,
+ to: snapshot,
+ generatedAt: snapshot.timestamp,
+ riskAssessment: analysis.riskAssessment,
+ insights: analysis.insights
+ )
+ }
+
+ let entry = HistoryEntry(
+ domain: snapshot.domain,
+ timestamp: snapshot.timestamp,
+ trackedDomainID: trackedDomainID,
+ note: trackedDomains.first(where: { $0.id == trackedDomainID })?.note,
+ dnsSections: snapshot.dnsSections,
+ sslInfo: snapshot.sslInfo,
+ httpHeaders: snapshot.httpHeaders,
+ reachabilityResults: snapshot.reachabilityResults,
+ ipGeolocation: snapshot.ipGeolocation,
+ emailSecurity: snapshot.emailSecurity,
+ mtaSts: snapshot.emailSecurity?.mtaSts,
+ ownership: snapshot.ownership,
+ ownershipHistory: snapshot.ownershipHistory,
+ ptrRecord: snapshot.ptrRecord,
+ redirectChain: snapshot.redirectChain,
+ subdomains: snapshot.subdomains,
+ extendedSubdomains: snapshot.extendedSubdomains,
+ dnsHistory: snapshot.dnsHistory,
+ domainPricing: snapshot.domainPricing,
+ portScanResults: snapshot.portScanResults,
+ hstsPreloaded: snapshot.hstsPreloaded,
+ availabilityResult: snapshot.availabilityResult,
+ suggestions: snapshot.suggestions,
+ appVersion: snapshot.appVersion,
+ resultSource: snapshot.resultSource,
+ dataSources: snapshot.dataSources,
+ provenanceBySection: snapshot.provenanceBySection,
+ availabilityConfidence: snapshot.availabilityConfidence,
+ ownershipConfidence: snapshot.ownershipConfidence,
+ subdomainConfidence: snapshot.subdomainConfidence,
+ emailSecurityConfidence: snapshot.emailSecurityConfidence,
+ geolocationConfidence: snapshot.geolocationConfidence,
+ errorDetails: snapshot.errorDetails,
+ isPartialSnapshot: snapshot.isPartialSnapshot,
+ validationIssues: snapshot.validationIssues,
+ resolverDisplayName: snapshot.resolverDisplayName,
+ resolverURLString: snapshot.resolverURLString,
+ totalLookupDurationMs: snapshot.totalLookupDurationMs,
+ primaryIP: Self.primaryIPAddress(from: snapshot),
+ finalRedirectURL: snapshot.redirectChain.last?.url,
+ tlsStatusSummary: Self.tlsSummary(from: snapshot),
+ emailSecuritySummary: Self.emailSummary(from: snapshot),
+ httpGradeSummary: snapshot.httpSecurityGrade ?? snapshot.httpHeadersError,
+ changeSummary: changeSummary,
+ sslError: snapshot.sslError,
+ httpHeadersError: snapshot.httpHeadersError,
+ reachabilityError: snapshot.reachabilityError,
+ ipGeolocationError: snapshot.ipGeolocationError,
+ emailSecurityError: snapshot.emailSecurityError,
+ ownershipError: snapshot.ownershipError,
+ ownershipHistoryError: snapshot.ownershipHistoryError,
+ ptrError: snapshot.ptrError,
+ redirectChainError: snapshot.redirectChainError,
+ subdomainsError: snapshot.subdomainsError,
+ extendedSubdomainsError: snapshot.extendedSubdomainsError,
+ dnsHistoryError: snapshot.dnsHistoryError,
+ domainPricingError: snapshot.domainPricingError,
+ portScanError: snapshot.portScanError
+ )
+
+ history.insert(entry, at: 0)
+ if history.count > maxHistoryEntries {
+ history = Array(history.prefix(maxHistoryEntries))
+ }
+
+ if let index = trackedDomains.firstIndex(where: { $0.id == trackedDomainID }) {
+ trackedDomains[index].lastSnapshotID = entry.id
+ trackedDomains[index].lastKnownAvailability = snapshot.availabilityResult?.status
+ trackedDomains[index].updatedAt = snapshot.timestamp
+ trackedDomains[index].lastChangeSummary = changeSummary
+ trackedDomains[index].lastChangeSeverity = changeSummary?.severity
+ trackedDomains[index].certificateWarningLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
+ trackedDomains[index].certificateDaysRemaining = snapshot.sslInfo?.daysUntilExpiry
+ }
+
+ return entry
+ }
+
+ private func alertDescriptor(
+ previousSnapshot: LookupSnapshot?,
+ snapshot: LookupSnapshot,
+ entry: HistoryEntry?
+ ) -> (severity: MonitoringAlertSeverity, message: String)? {
+ let changedLabels = Set(
+ (previousSnapshot.map { DomainDiffService.diff(from: $0, to: snapshot) } ?? [])
+ .flatMap(\.items)
+ .filter(\.hasChanges)
+ .map(\.label)
+ )
+
+ if changedLabels.contains("Availability") {
+ return (.critical, "Availability changed")
+ }
+ if changedLabels.contains("Primary IP") {
+ return (.critical, "Primary IP changed")
+ }
+ if changedLabels.contains("Redirect Target") {
+ return (.critical, "Redirect target changed")
+ }
+
+ let ownershipLabels: Set<String> = [
+ "Registrar",
+ "Registration Date",
+ "Expiration Date",
+ "Ownership Status",
+ "Abuse Contact"
+ ]
+ if !changedLabels.isDisjoint(with: ownershipLabels) {
+ return (.warning, "Ownership changed")
+ }
+ if changedLabels.contains("Nameservers") || changedLabels.contains(where: { $0.hasSuffix("Records") }) {
+ return (.warning, "DNS changed")
+ }
+
+ let oldCertificateLevel = previousSnapshot.map { DomainDiffService.certificateWarningLevel(for: $0) } ?? .none
+ let newCertificateLevel = DomainDiffService.certificateWarningLevel(for: snapshot)
+ if newCertificateLevel != .none, newCertificateLevel != oldCertificateLevel {
+ let daysRemaining = snapshot.sslInfo?.daysUntilExpiry ?? 0
+ return (.warning, "Certificate expires in \(daysRemaining) days")
+ }
+
+ if entry?.changeSummary?.hasChanges == true, let message = entry?.changeSummary?.message {
+ return (.info, message)
+ }
+
+ return nil
+ }
+
+ private static func resolvedSnapshotAfterFallback(
+ _ snapshot: LookupSnapshot,
+ previousSnapshot: LookupSnapshot?
+ ) -> LookupSnapshot {
+ guard shouldFallbackToSnapshot(snapshot), let previousSnapshot else {
+ return snapshot
+ }
+
+ return LookupSnapshot(
+ historyEntryID: previousSnapshot.historyEntryID,
+ domain: previousSnapshot.domain,
+ timestamp: previousSnapshot.timestamp,
+ trackedDomainID: previousSnapshot.trackedDomainID,
+ note: previousSnapshot.note,
+ appVersion: previousSnapshot.appVersion,
+ resolverDisplayName: previousSnapshot.resolverDisplayName,
+ resolverURLString: previousSnapshot.resolverURLString,
+ dataSources: previousSnapshot.dataSources,
+ provenanceBySection: previousSnapshot.provenanceBySection,
+ availabilityConfidence: previousSnapshot.availabilityConfidence,
+ ownershipConfidence: previousSnapshot.ownershipConfidence,
+ subdomainConfidence: previousSnapshot.subdomainConfidence,
+ emailSecurityConfidence: previousSnapshot.emailSecurityConfidence,
+ geolocationConfidence: previousSnapshot.geolocationConfidence,
+ errorDetails: previousSnapshot.errorDetails,
+ isPartialSnapshot: previousSnapshot.isPartialSnapshot,
+ validationIssues: previousSnapshot.validationIssues,
+ totalLookupDurationMs: previousSnapshot.totalLookupDurationMs,
+ dnsSections: previousSnapshot.dnsSections,
+ dnsError: previousSnapshot.dnsError,
+ availabilityResult: previousSnapshot.availabilityResult,
+ suggestions: previousSnapshot.suggestions,
+ sslInfo: previousSnapshot.sslInfo,
+ sslError: previousSnapshot.sslError,
+ hstsPreloaded: previousSnapshot.hstsPreloaded,
+ httpHeaders: previousSnapshot.httpHeaders,
+ httpSecurityGrade: previousSnapshot.httpSecurityGrade,
+ httpStatusCode: previousSnapshot.httpStatusCode,
+ httpResponseTimeMs: previousSnapshot.httpResponseTimeMs,
+ httpProtocol: previousSnapshot.httpProtocol,
+ http3Advertised: previousSnapshot.http3Advertised,
+ httpHeadersError: previousSnapshot.httpHeadersError,
+ reachabilityResults: previousSnapshot.reachabilityResults,
+ reachabilityError: previousSnapshot.reachabilityError,
+ ipGeolocation: previousSnapshot.ipGeolocation,
+ ipGeolocationError: previousSnapshot.ipGeolocationError,
+ emailSecurity: previousSnapshot.emailSecurity,
+ emailSecurityError: previousSnapshot.emailSecurityError,
+ ownership: previousSnapshot.ownership,
+ ownershipError: previousSnapshot.ownershipError,
+ ownershipHistory: previousSnapshot.ownershipHistory,
+ ownershipHistoryError: previousSnapshot.ownershipHistoryError,
+ ptrRecord: previousSnapshot.ptrRecord,
+ ptrError: previousSnapshot.ptrError,
+ redirectChain: previousSnapshot.redirectChain,
+ redirectChainError: previousSnapshot.redirectChainError,
+ subdomains: previousSnapshot.subdomains,
+ subdomainsError: previousSnapshot.subdomainsError,
+ extendedSubdomains: previousSnapshot.extendedSubdomains,
+ extendedSubdomainsError: previousSnapshot.extendedSubdomainsError,
+ dnsHistory: previousSnapshot.dnsHistory,
+ dnsHistoryError: previousSnapshot.dnsHistoryError,
+ domainPricing: previousSnapshot.domainPricing,
+ domainPricingError: previousSnapshot.domainPricingError,
+ portScanResults: previousSnapshot.portScanResults,
+ portScanError: previousSnapshot.portScanError,
+ changeSummary: previousSnapshot.changeSummary,
+ resultSource: .snapshot,
+ cachedSections: [],
+ statusMessage: "Last known result • \(previousSnapshot.timestamp.formatted(date: .abbreviated, time: .shortened))"
+ )
+ }
+
+ private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
+ let candidateMessages = [
+ snapshot.dnsError,
+ snapshot.httpHeadersError,
+ snapshot.sslError,
+ snapshot.ownershipError,
+ snapshot.subdomainsError,
+ snapshot.redirectChainError,
+ snapshot.ipGeolocationError
+ ]
+ .compactMap { $0?.lowercased() }
+
+ guard !candidateMessages.isEmpty else { return false }
+ let connectivityFailure = candidateMessages.allSatisfy { message in
+ message.hasPrefix("network error:")
+ || message.hasPrefix("timeout:")
+ || message.hasPrefix("rate limit:")
+ }
+
+ let hasMaterialData = !snapshot.dnsSections.isEmpty
+ || !snapshot.httpHeaders.isEmpty
+ || snapshot.sslInfo != nil
+ || snapshot.ownership != nil
+ || !snapshot.subdomains.isEmpty
+
+ return connectivityFailure && !hasMaterialData
+ }
+
+ private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? {
+ snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value
+ }
+
+ private static func tlsSummary(from snapshot: LookupSnapshot) -> String? {
+ guard let sslInfo = snapshot.sslInfo else { return snapshot.sslError }
+ switch DomainDiffService.certificateWarningLevel(for: snapshot) {
+ case .critical:
+ return "Critical (\(sslInfo.daysUntilExpiry)d)"
+ case .warning:
+ return "Warning (\(sslInfo.daysUntilExpiry)d)"
+ case .none:
+ return "Healthy (\(sslInfo.daysUntilExpiry)d)"
+ }
+ }
+
+ private static func emailSummary(from snapshot: LookupSnapshot) -> String? {
+ if let emailSecurity = snapshot.emailSecurity {
+ return [
+ "spf:\(emailSecurity.spf.found)",
+ "dmarc:\(emailSecurity.dmarc.found)",
+ "dkim:\(emailSecurity.dkim.found)",
+ "bimi:\(emailSecurity.bimi.found)",
+ "mta-sts:\(emailSecurity.mtaSts?.txtFound == true)"
+ ].joined(separator: "|")
+ }
+ return snapshot.emailSecurityError
+ }
+}