krz/domain-dig

an ios app for DNS & SSL analysis

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

v1.4.0: DomainDig/DomainViewModel.swift · raw

  1import Foundation
  2import SwiftUI
  3
  4@MainActor
  5@Observable
  6final class DomainViewModel {
  7    var domain: String = ""
  8
  9    // DNS
 10    var dnsSections: [DNSSection] = []
 11    var dnsLoading = false
 12    var dnsError: String?
 13
 14    // SSL
 15    var sslInfo: SSLCertificateInfo?
 16    var sslLoading = false
 17    var sslError: String?
 18    var hstsPreloaded: Bool?
 19    var hstsLoading = false
 20
 21    // HTTP Headers
 22    var httpHeaders: [HTTPHeader] = []
 23    var httpHeadersLoading = false
 24    var httpHeadersError: String?
 25
 26    // Reachability
 27    var reachabilityResults: [PortReachability] = []
 28    var reachabilityLoading = false
 29    var reachabilityError: String?
 30
 31    // IP Geolocation
 32    var ipGeolocation: IPGeolocation?
 33    var ipGeolocationLoading = false
 34    var ipGeolocationError: String?
 35
 36    // Email Security
 37    var emailSecurity: EmailSecurityResult?
 38    var emailSecurityLoading = false
 39    var emailSecurityError: String?
 40
 41    // PTR / Reverse DNS
 42    var ptrRecord: String?
 43    var ptrLoading = false
 44    var ptrError: String?
 45
 46    // Redirect Chain
 47    var redirectChain: [RedirectHop] = []
 48    var redirectChainLoading = false
 49    var redirectChainError: String?
 50
 51    // Port Scan
 52    var portScanResults: [PortScanResult] = []
 53    var portScanLoading = false
 54    var portScanError: String?
 55
 56    var hasRun = false
 57    private(set) var searchedDomain: String = ""
 58
 59    // MARK: - Recent Searches
 60
 61    private static let recentSearchesKey = "recentSearches"
 62    private static let maxRecent = 20
 63
 64    var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
 65
 66    // MARK: - Saved Domains
 67
 68    private static let savedDomainsKey = "savedDomains"
 69
 70    var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
 71
 72    var isCurrentDomainSaved: Bool {
 73        !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
 74    }
 75
 76    func toggleSavedDomain() {
 77        if isCurrentDomainSaved {
 78            savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
 79        } else {
 80            savedDomains.append(searchedDomain)
 81        }
 82        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
 83    }
 84
 85    func removeSavedDomain(_ domain: String) {
 86        savedDomains.removeAll { $0 == domain }
 87        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
 88    }
 89
 90    func removeSavedDomains(at offsets: IndexSet) {
 91        savedDomains.remove(atOffsets: offsets)
 92        UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
 93    }
 94
 95    // MARK: - History
 96
 97    private static let historyKey = "lookupHistory"
 98    private static let maxHistory = 50
 99
100    var history: [HistoryEntry] = {
101        guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
102              let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
103            return []
104        }
105        return entries
106    }()
107
108    private func saveHistoryEntry() {
109        let entry = HistoryEntry(
110            domain: searchedDomain,
111            timestamp: Date(),
112            dnsSections: dnsSections,
113            sslInfo: sslInfo,
114            httpHeaders: httpHeaders,
115            reachabilityResults: reachabilityResults,
116            ipGeolocation: ipGeolocation,
117            emailSecurity: emailSecurity,
118            ptrRecord: ptrRecord,
119            redirectChain: redirectChain,
120            portScanResults: portScanResults,
121            hstsPreloaded: hstsPreloaded
122        )
123        history.insert(entry, at: 0)
124        if history.count > Self.maxHistory {
125            history = Array(history.prefix(Self.maxHistory))
126        }
127        if let data = try? JSONEncoder().encode(history) {
128            UserDefaults.standard.set(data, forKey: Self.historyKey)
129        }
130    }
131
132    func removeHistoryEntries(at offsets: IndexSet) {
133        history.remove(atOffsets: offsets)
134        if let data = try? JSONEncoder().encode(history) {
135            UserDefaults.standard.set(data, forKey: Self.historyKey)
136        }
137    }
138
139    // MARK: - Computed
140
141    var trimmedDomain: String {
142        domain
143            .trimmingCharacters(in: .whitespacesAndNewlines)
144            .replacingOccurrences(of: "https://", with: "")
145            .replacingOccurrences(of: "http://", with: "")
146            .components(separatedBy: "/").first ?? ""
147    }
148
149    /// True when all lookups have finished (regardless of success/failure).
150    var resultsLoaded: Bool {
151        hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading
152            && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
153            && !redirectChainLoading && !portScanLoading
154    }
155
156    // MARK: - Reset
157
158    func reset() {
159        hasRun = false
160        searchedDomain = ""
161        dnsSections = []
162        dnsError = nil
163        dnsLoading = false
164        sslInfo = nil
165        sslError = nil
166        sslLoading = false
167        hstsPreloaded = nil
168        hstsLoading = false
169        httpHeaders = []
170        httpHeadersError = nil
171        httpHeadersLoading = false
172        reachabilityResults = []
173        reachabilityError = nil
174        reachabilityLoading = false
175        ipGeolocation = nil
176        ipGeolocationError = nil
177        ipGeolocationLoading = false
178        emailSecurity = nil
179        emailSecurityError = nil
180        emailSecurityLoading = false
181        ptrRecord = nil
182        ptrError = nil
183        ptrLoading = false
184        redirectChain = []
185        redirectChainError = nil
186        redirectChainLoading = false
187        portScanResults = []
188        portScanError = nil
189        portScanLoading = false
190    }
191
192    // MARK: - Run
193
194    func run() {
195        let target = trimmedDomain
196        guard !target.isEmpty else { return }
197
198        addRecentSearch(target)
199        searchedDomain = target
200        hasRun = true
201
202        // Reset all state
203        dnsSections = []
204        dnsError = nil
205        dnsLoading = true
206        sslInfo = nil
207        sslError = nil
208        sslLoading = true
209        hstsPreloaded = nil
210        hstsLoading = true
211        httpHeaders = []
212        httpHeadersError = nil
213        httpHeadersLoading = true
214        reachabilityResults = []
215        reachabilityError = nil
216        reachabilityLoading = true
217        ipGeolocation = nil
218        ipGeolocationError = nil
219        ipGeolocationLoading = true
220        emailSecurity = nil
221        emailSecurityError = nil
222        emailSecurityLoading = true
223        ptrRecord = nil
224        ptrError = nil
225        ptrLoading = true
226        redirectChain = []
227        redirectChainError = nil
228        redirectChainLoading = true
229        portScanResults = []
230        portScanError = nil
231        portScanLoading = true
232
233        Task {
234            await withTaskGroup(of: Void.self) { group in
235                // DNS  chained: email security, PTR, geolocation
236                group.addTask { @MainActor in
237                    await self.runDNS(domain: target)
238                    // These depend on DNS results and run in parallel after DNS
239                    await withTaskGroup(of: Void.self) { postDNS in
240                        postDNS.addTask { @MainActor in
241                            await self.runEmailSecurity(domain: target)
242                        }
243                        postDNS.addTask { @MainActor in
244                            await self.runReverseDNS()
245                        }
246                        postDNS.addTask { @MainActor in
247                            await self.runIPGeolocation()
248                        }
249                    }
250                }
251                group.addTask { @MainActor in
252                    await self.runSSL(domain: target)
253                }
254                group.addTask { @MainActor in
255                    await self.runHSTSPreload(domain: target)
256                }
257                group.addTask { @MainActor in
258                    await self.runHTTPHeaders(domain: target)
259                }
260                group.addTask { @MainActor in
261                    await self.runReachability(domain: target)
262                }
263                group.addTask { @MainActor in
264                    await self.runRedirectChain(domain: target)
265                }
266                group.addTask { @MainActor in
267                    await self.runPortScan(domain: target)
268                }
269            }
270            // Save history after all lookups complete so the snapshot is complete
271            saveHistoryEntry()
272        }
273    }
274
275    // MARK: - Lookup Methods
276
277    private func runDNS(domain: String) async {
278        do {
279            let sections = await DNSLookupService.lookupAll(domain: domain)
280            dnsSections = sections
281        }
282        dnsLoading = false
283    }
284
285    private func runSSL(domain: String) async {
286        do {
287            let info = try await SSLCheckService.check(domain: domain)
288            sslInfo = info
289        } catch {
290            sslError = error.localizedDescription
291        }
292        sslLoading = false
293    }
294
295    private func runHSTSPreload(domain: String) async {
296        hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain)
297        hstsLoading = false
298    }
299
300    private func runHTTPHeaders(domain: String) async {
301        do {
302            let headers = try await HTTPHeadersService.fetch(domain: domain)
303            httpHeaders = headers
304        } catch {
305            httpHeadersError = error.localizedDescription
306        }
307        httpHeadersLoading = false
308    }
309
310    private func runReachability(domain: String) async {
311        let results = await ReachabilityService.checkAll(domain: domain)
312        reachabilityResults = results
313        reachabilityLoading = false
314    }
315
316    private func runIPGeolocation() async {
317        // Find the first A record IP
318        guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
319              let firstIP = aSection.records.first?.value else {
320            ipGeolocationError = "No A record available"
321            ipGeolocationLoading = false
322            return
323        }
324        do {
325            let geo = try await IPGeolocationService.lookup(ip: firstIP)
326            ipGeolocation = geo
327        } catch {
328            ipGeolocationError = error.localizedDescription
329        }
330        ipGeolocationLoading = false
331    }
332
333    private func runEmailSecurity(domain: String) async {
334        // Extract TXT records from already-fetched DNS sections
335        let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
336        let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
337        emailSecurity = result
338        emailSecurityLoading = false
339    }
340
341    private func runReverseDNS() async {
342        guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
343              let firstIP = aSection.records.first?.value else {
344            ptrError = "No A record available"
345            ptrLoading = false
346            return
347        }
348        let result = await ReverseDNSService.lookup(ip: firstIP)
349        ptrRecord = result
350        if result == nil {
351            ptrError = "No PTR record found"
352        }
353        ptrLoading = false
354    }
355
356    private func runRedirectChain(domain: String) async {
357        do {
358            let hops = try await RedirectChainService.trace(domain: domain)
359            redirectChain = hops
360        } catch {
361            redirectChainError = error.localizedDescription
362        }
363        redirectChainLoading = false
364    }
365
366    private func runPortScan(domain: String) async {
367        let results = await PortScanService.scanAll(domain: domain)
368        portScanResults = results
369        portScanLoading = false
370    }
371
372    // MARK: - Export
373
374    func exportText() -> String {
375        return Self.formatExportText(
376            domain: searchedDomain,
377            date: Date(),
378            dnsSections: dnsSections,
379            sslInfo: sslInfo,
380            sslError: sslError,
381            hstsPreloaded: hstsPreloaded,
382            httpHeaders: httpHeaders,
383            httpHeadersError: httpHeadersError,
384            reachabilityResults: reachabilityResults,
385            ipGeolocation: ipGeolocation,
386            ipGeolocationError: ipGeolocationError,
387            emailSecurity: emailSecurity,
388            ptrRecord: ptrRecord,
389            redirectChain: redirectChain,
390            portScanResults: portScanResults
391        )
392    }
393
394    static func formatExportText(
395        domain: String,
396        date: Date,
397        dnsSections: [DNSSection],
398        sslInfo: SSLCertificateInfo?,
399        sslError: String? = nil,
400        hstsPreloaded: Bool? = nil,
401        httpHeaders: [HTTPHeader],
402        httpHeadersError: String? = nil,
403        reachabilityResults: [PortReachability],
404        ipGeolocation: IPGeolocation?,
405        ipGeolocationError: String? = nil,
406        emailSecurity: EmailSecurityResult? = nil,
407        ptrRecord: String? = nil,
408        redirectChain: [RedirectHop] = [],
409        portScanResults: [PortScanResult] = []
410    ) -> String {
411        let dateFmt = DateFormatter()
412        dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
413
414        var lines: [String] = [
415            "DomainDig Export",
416            "Domain: \(domain)",
417            "Date: \(dateFmt.string(from: date))",
418        ]
419
420        // Reachability
421        if !reachabilityResults.isEmpty {
422            lines.append("")
423            lines.append("Reachability")
424            lines.append("------------")
425            for result in reachabilityResults {
426                if result.reachable, let ms = result.latencyMs {
427                    lines.append("  Port \(result.port)  \(ms)ms  Reachable")
428                } else {
429                    lines.append("  Port \(result.port)  —  Unreachable")
430                }
431            }
432        }
433
434        // Redirect Chain
435        if !redirectChain.isEmpty {
436            lines.append("")
437            lines.append("Redirect Chain")
438            lines.append("--------------")
439            if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) {
440                lines.append("  No redirects — direct connection")
441            } else {
442                for hop in redirectChain {
443                    let final = hop.isFinal ? "  (final)" : ""
444                    lines.append("  \(hop.stepNumber)  \(hop.statusCode)  \(hop.url)\(final)")
445                }
446            }
447        }
448
449        // DNS
450        lines.append("")
451        lines.append("DNS Records")
452        lines.append("-----------")
453        for section in dnsSections {
454            lines.append(section.recordType.rawValue)
455            if let error = section.error {
456                lines.append("  Error: \(error)")
457            } else if section.records.isEmpty {
458                lines.append("  No records found")
459            } else {
460                for record in section.records {
461                    lines.append("  \(record.value)  TTL \(record.ttl)")
462                }
463            }
464            if !section.wildcardRecords.isEmpty {
465                lines.append("*.\(domain)")
466                for record in section.wildcardRecords {
467                    lines.append("  \(record.value)  TTL \(record.ttl)")
468                }
469            }
470        }
471
472        // PTR
473        if let ptr = ptrRecord {
474            lines.append("PTR (Reverse DNS)")
475            lines.append("  \(ptr)")
476        }
477
478        // Email Security
479        if let email = emailSecurity {
480            lines.append("")
481            lines.append("Email Security")
482            lines.append("--------------")
483            lines.append("  SPF:   \(email.spf.found ? "" : "")  \(email.spf.value ?? "No record found")")
484            lines.append("  DMARC: \(email.dmarc.found ? "" : "")  \(email.dmarc.value ?? "No record found")")
485            lines.append("  DKIM:  \(email.dkim.found ? "" : "")  \(email.dkim.value ?? "No record found")")
486        }
487
488        // SSL
489        if let info = sslInfo {
490            let certDateFmt = DateFormatter()
491            certDateFmt.dateStyle = .medium
492            certDateFmt.timeStyle = .none
493
494            lines.append("")
495            lines.append("SSL / TLS Certificate")
496            lines.append("---------------------")
497            lines.append("Common Name: \(info.commonName)")
498            lines.append("Issuer: \(info.issuer)")
499            lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))")
500            lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))")
501            lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))")
502            lines.append("Days Until Expiry: \(info.daysUntilExpiry)")
503            lines.append("Chain Depth: \(info.chainDepth)")
504            if let tlsVersion = info.tlsVersion {
505                lines.append("TLS Version: \(tlsVersion)")
506            }
507            if let cipherSuite = info.cipherSuite {
508                lines.append("Cipher Suite: \(cipherSuite)")
509            }
510            if let hstsPreloaded {
511                lines.append("HSTS Preload: \(hstsPreloaded ? "Preloaded" : "Not preloaded")")
512            }
513            if !info.chain.isEmpty {
514                lines.append("Certificate Chain:")
515                for certificate in info.chain {
516                    lines.append("  Subject: \(certificate.subject)")
517                    lines.append("  Issuer: \(certificate.issuer)")
518                }
519            }
520        } else if let error = sslError {
521            lines.append("")
522            lines.append("SSL / TLS Certificate")
523            lines.append("---------------------")
524            lines.append("Error: \(error)")
525        }
526
527        // HTTP Headers
528        if !httpHeaders.isEmpty {
529            lines.append("")
530            lines.append("HTTP Headers")
531            lines.append("------------")
532            for header in httpHeaders {
533                lines.append("  \(header.name): \(header.value)")
534            }
535        } else if let error = httpHeadersError {
536            lines.append("")
537            lines.append("HTTP Headers")
538            lines.append("------------")
539            lines.append("Error: \(error)")
540        }
541
542        // IP Geolocation
543        if let geo = ipGeolocation {
544            lines.append("")
545            lines.append("IP Location")
546            lines.append("-----------")
547            lines.append("IP: \(geo.ip)")
548            if let org = geo.org { lines.append("Org: \(org)") }
549            let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
550            if !location.isEmpty { lines.append("Location: \(location)") }
551            if let lat = geo.latitude, let lon = geo.longitude {
552                lines.append("Coordinates: \(lat), \(lon)")
553            }
554        } else if let error = ipGeolocationError, error != "No A record available" {
555            lines.append("")
556            lines.append("IP Location")
557            lines.append("-----------")
558            lines.append("Error: \(error)")
559        }
560
561        // Open Ports
562        if !portScanResults.isEmpty {
563            lines.append("")
564            lines.append("Open Ports")
565            lines.append("----------")
566            let openPorts = portScanResults.filter { $0.open }
567            if openPorts.isEmpty {
568                lines.append("  No open ports detected")
569            } else {
570                for port in openPorts {
571                    lines.append("  \(port.port)  \(port.service)")
572                }
573            }
574            let closedPorts = portScanResults.filter { !$0.open }
575            if !closedPorts.isEmpty {
576                lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))")
577            }
578        }
579
580        return lines.joined(separator: "\n")
581    }
582
583    // MARK: - Recent Searches
584
585    private func addRecentSearch(_ domain: String) {
586        recentSearches.removeAll { $0.lowercased() == domain.lowercased() }
587        recentSearches.insert(domain, at: 0)
588        if recentSearches.count > Self.maxRecent {
589            recentSearches = Array(recentSearches.prefix(Self.maxRecent))
590        }
591        UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey)
592    }
593
594    func clearRecentSearches() {
595        recentSearches.removeAll()
596        UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey)
597    }
598}