krz/domain-dig

an ios app for DNS & SSL analysis

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

7b880ea410f37a7df22594ed9b268448621db4d8

unsigned

author: Christian Cleberg <hello@cleberg.net> · 2026-07-20T20:33:50Z

fix: report unreachable domains instead of 'No meaningful changes'

Closes #10.

resolvedSnapshotAfterFallback replaces a failed lookup's snapshot with
the previous one, so alertDescriptor compared the old snapshot against
itself, found matching hashes, and the run reported 'No meaningful
changes' for a domain that was never actually reached. Nothing in the
UI or the monitoring log distinguished that from a genuine no-change.

MonitoringDomainResult now carries unreachableReason, set when the
fallback fires. It is Optional so already-persisted monitoring logs
still decode. The run summary reads 'Could not check — kept the previous
result' with the underlying error, and monitoringEvents emits a warning-
severity monitoringFailure so configured integrations hear about it
rather than seeing silence.
 DomainDig/DomainMonitoringService.swift | 43 +++++++++++++++++++++++++++++++--
 DomainDig/Models.swift                  |  8 +++++-
 2 files changed, 48 insertions(+), 3 deletions(-)

diff --git a/DomainDig/DomainMonitoringService.swift b/DomainDig/DomainMonitoringService.swift
index 6fb5fc9..917ede0 100644
--- a/DomainDig/DomainMonitoringService.swift
+++ b/DomainDig/DomainMonitoringService.swift
@@ -291,6 +291,12 @@ final class DomainMonitoringService {
                 previousSnapshot: previousSnapshot
             )
             let snapshot = Self.resolvedSnapshotAfterFallback(inspectedSnapshot, previousSnapshot: previousSnapshot)
+            // When the fallback fires, `snapshot` *is* `previousSnapshot`, so
+            // every comparison below is old-against-old and would otherwise
+            // report "No meaningful changes" for a domain we never reached.
+            let unreachableReason = (previousSnapshot != nil && Self.shouldFallbackToSnapshot(inspectedSnapshot))
+                ? Self.firstErrorMessage(in: inspectedSnapshot)
+                : nil
             let savedEntry: HistoryEntry?
             if snapshot.statusMessage == nil {
                 savedEntry = persistSnapshot(
@@ -330,14 +336,16 @@ final class DomainMonitoringService {
                 historyEntryID: savedEntry?.id ?? snapshot.historyEntryID,
                 checkedAt: now,
                 didChange: alertDescriptor != nil,
-                summaryMessage: snapshot.statusMessage
+                summaryMessage: unreachableReason.map { "Could not check — kept the previous result. \($0)" }
+                    ?? snapshot.statusMessage
                     ?? alertDescriptor?.message
                     ?? savedEntry?.changeSummary?.message
                     ?? "No meaningful changes",
                 alertSeverity: alertDescriptor?.severity,
                 certificateWarningLevel: DomainDiffService.certificateWarningLevel(for: snapshot),
                 resultSource: snapshot.resultSource,
-                errorMessage: snapshot.statusMessage
+                errorMessage: snapshot.statusMessage,
+                unreachableReason: unreachableReason
             )
             results.append(result)
 
@@ -397,6 +405,21 @@ final class DomainMonitoringService {
                 )
             }
 
+            if let unreachableReason = result.unreachableReason {
+                return MonitoringEvent(
+                    type: .monitoringFailure,
+                    severity: .warning,
+                    domain: result.domain,
+                    timestamp: result.checkedAt,
+                    summary: result.summaryMessage,
+                    details: [
+                        "reason": unreachableReason,
+                        "trigger": log.trigger.rawValue,
+                        "resultSource": result.resultSource.rawValue
+                    ]
+                )
+            }
+
             if result.certificateWarningLevel == .critical {
                 return MonitoringEvent(
                     type: .certificateExpiring,
@@ -943,6 +966,22 @@ final class DomainMonitoringService {
         )
     }
 
+    /// First reported error on a snapshot, used to explain a fallback. Mirrors
+    /// the ordering `shouldFallbackToSnapshot` inspects.
+    private static func firstErrorMessage(in snapshot: LookupSnapshot) -> String {
+        [
+            snapshot.dnsError,
+            snapshot.httpHeadersError,
+            snapshot.sslError,
+            snapshot.ownershipError,
+            snapshot.subdomainsError,
+            snapshot.redirectChainError,
+            snapshot.ipGeolocationError
+        ]
+        .compactMap { $0 }
+        .first ?? "The lookup could not reach the domain."
+    }
+
     private static func shouldFallbackToSnapshot(_ snapshot: LookupSnapshot) -> Bool {
         let candidateMessages = [
             snapshot.dnsError,
diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift
index b86fc07..6989cba 100644
--- a/DomainDig/Models.swift
+++ b/DomainDig/Models.swift
@@ -1544,6 +1544,10 @@ struct MonitoringDomainResult: Codable, Identifiable, Equatable {
     let certificateWarningLevel: CertificateWarningLevel
     let resultSource: LookupResultSource
     let errorMessage: String?
+    /// Non-nil when the lookup failed and the previous snapshot was reused, so
+    /// this run compared the old data against itself and cannot claim the
+    /// domain is unchanged. Optional so already-persisted logs still decode.
+    let unreachableReason: String?
 
     init(
         id: UUID = UUID(),
@@ -1555,7 +1559,8 @@ struct MonitoringDomainResult: Codable, Identifiable, Equatable {
         alertSeverity: MonitoringAlertSeverity?,
         certificateWarningLevel: CertificateWarningLevel,
         resultSource: LookupResultSource,
-        errorMessage: String? = nil
+        errorMessage: String? = nil,
+        unreachableReason: String? = nil
     ) {
         self.id = id
         self.domain = domain
@@ -1567,6 +1572,7 @@ struct MonitoringDomainResult: Codable, Identifiable, Equatable {
         self.certificateWarningLevel = certificateWarningLevel
         self.resultSource = resultSource
         self.errorMessage = errorMessage
+        self.unreachableReason = unreachableReason
     }
 }