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}