krz/domain-dig

an ios app for DNS & SSL analysis

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

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