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}