DomainDig/DomainViewModel.swift

ede15c586af71b8d6f2c190ffdad0324414cac06
domain-dig/DomainDig/DomainViewModel.swift history · blame · raw

565 lines · 19133 bytes

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