krz/domain-dig

an ios app for DNS & SSL analysis

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

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