krz/domain-dig

an ios app for DNS & SSL analysis

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

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