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