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