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