summaryrefslogtreecommitdiff
path: root/DomainDig/DomainViewModel.swift
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-03-18 11:59:38 -0500
committerChristian Cleberg <[email protected]>2026-03-18 11:59:38 -0500
commit376e7b55b4a0c4f54faa0c2828dea4fd17dff288 (patch)
tree540d11ad423f14975553f2490617e05aea61ae2c /DomainDig/DomainViewModel.swift
parente11b7d29a6aa9e52f63fcb35ee7ed9127850fffe (diff)
downloaddomain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.tar.gz
domain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.tar.bz2
domain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.zip
realigning commits
Diffstat (limited to 'DomainDig/DomainViewModel.swift')
-rw-r--r--DomainDig/DomainViewModel.swift428
1 files changed, 418 insertions, 10 deletions
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 2965dde..afb7e3c 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -6,22 +6,135 @@ import SwiftUI
final class DomainViewModel {
var domain: String = ""
+ // DNS
var dnsSections: [DNSSection] = []
var dnsLoading = false
var dnsError: String?
+ // SSL
var sslInfo: SSLCertificateInfo?
var sslLoading = false
var sslError: String?
+ // HTTP Headers
+ var httpHeaders: [HTTPHeader] = []
+ var httpHeadersLoading = false
+ var httpHeadersError: String?
+
+ // Reachability
+ var reachabilityResults: [PortReachability] = []
+ var reachabilityLoading = false
+ var reachabilityError: String?
+
+ // IP Geolocation
+ var ipGeolocation: IPGeolocation?
+ var ipGeolocationLoading = false
+ var ipGeolocationError: String?
+
+ // Email Security
+ var emailSecurity: EmailSecurityResult?
+ var emailSecurityLoading = false
+ var emailSecurityError: String?
+
+ // PTR / Reverse DNS
+ var ptrRecord: String?
+ var ptrLoading = false
+ var ptrError: String?
+
+ // Redirect Chain
+ var redirectChain: [RedirectHop] = []
+ var redirectChainLoading = false
+ var redirectChainError: String?
+
+ // Port Scan
+ var portScanResults: [PortScanResult] = []
+ var portScanLoading = false
+ var portScanError: String?
+
var hasRun = false
private(set) var searchedDomain: String = ""
+ // MARK: - Recent Searches
+
private static let recentSearchesKey = "recentSearches"
private static let maxRecent = 20
var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
+ // MARK: - Saved Domains
+
+ private static let savedDomainsKey = "savedDomains"
+
+ var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
+
+ var isCurrentDomainSaved: Bool {
+ !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
+ }
+
+ func toggleSavedDomain() {
+ if isCurrentDomainSaved {
+ savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
+ } else {
+ savedDomains.append(searchedDomain)
+ }
+ UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ }
+
+ func removeSavedDomain(_ domain: String) {
+ savedDomains.removeAll { $0 == domain }
+ UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ }
+
+ func removeSavedDomains(at offsets: IndexSet) {
+ savedDomains.remove(atOffsets: offsets)
+ UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
+ }
+
+ // MARK: - History
+
+ private static let historyKey = "lookupHistory"
+ private static let maxHistory = 50
+
+ var history: [HistoryEntry] = {
+ guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
+ let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
+ return []
+ }
+ return entries
+ }()
+
+ private func saveHistoryEntry() {
+ let entry = HistoryEntry(
+ domain: searchedDomain,
+ timestamp: Date(),
+ dnsSections: dnsSections,
+ sslInfo: sslInfo,
+ httpHeaders: httpHeaders,
+ reachabilityResults: reachabilityResults,
+ ipGeolocation: ipGeolocation,
+ emailSecurity: emailSecurity,
+ ptrRecord: ptrRecord,
+ redirectChain: redirectChain,
+ portScanResults: portScanResults
+ )
+ history.insert(entry, at: 0)
+ if history.count > Self.maxHistory {
+ history = Array(history.prefix(Self.maxHistory))
+ }
+ if let data = try? JSONEncoder().encode(history) {
+ UserDefaults.standard.set(data, forKey: Self.historyKey)
+ }
+ }
+
+ func removeHistoryEntries(at offsets: IndexSet) {
+ history.remove(atOffsets: offsets)
+ if let data = try? JSONEncoder().encode(history) {
+ UserDefaults.standard.set(data, forKey: Self.historyKey)
+ }
+ }
+
+ // MARK: - Computed
+
var trimmedDomain: String {
domain
.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -30,6 +143,49 @@ final class DomainViewModel {
.components(separatedBy: "/").first ?? ""
}
+ /// True when all lookups have finished (regardless of success/failure).
+ var resultsLoaded: Bool {
+ hasRun && !dnsLoading && !sslLoading && !httpHeadersLoading && !reachabilityLoading
+ && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
+ && !redirectChainLoading && !portScanLoading
+ }
+
+ // MARK: - Reset
+
+ func reset() {
+ hasRun = false
+ searchedDomain = ""
+ dnsSections = []
+ dnsError = nil
+ dnsLoading = false
+ sslInfo = nil
+ sslError = nil
+ sslLoading = false
+ httpHeaders = []
+ httpHeadersError = nil
+ httpHeadersLoading = false
+ reachabilityResults = []
+ reachabilityError = nil
+ reachabilityLoading = false
+ ipGeolocation = nil
+ ipGeolocationError = nil
+ ipGeolocationLoading = false
+ emailSecurity = nil
+ emailSecurityError = nil
+ emailSecurityLoading = false
+ ptrRecord = nil
+ ptrError = nil
+ ptrLoading = false
+ redirectChain = []
+ redirectChainError = nil
+ redirectChainLoading = false
+ portScanResults = []
+ portScanError = nil
+ portScanLoading = false
+ }
+
+ // MARK: - Run
+
func run() {
let target = trimmedDomain
guard !target.isEmpty else { return }
@@ -37,25 +193,77 @@ final class DomainViewModel {
addRecentSearch(target)
searchedDomain = target
hasRun = true
+
+ // Reset all state
dnsSections = []
dnsError = nil
dnsLoading = true
sslInfo = nil
sslError = nil
sslLoading = true
+ httpHeaders = []
+ httpHeadersError = nil
+ httpHeadersLoading = true
+ reachabilityResults = []
+ reachabilityError = nil
+ reachabilityLoading = true
+ ipGeolocation = nil
+ ipGeolocationError = nil
+ ipGeolocationLoading = true
+ emailSecurity = nil
+ emailSecurityError = nil
+ emailSecurityLoading = true
+ ptrRecord = nil
+ ptrError = nil
+ ptrLoading = true
+ redirectChain = []
+ redirectChainError = nil
+ redirectChainLoading = true
+ portScanResults = []
+ portScanError = nil
+ portScanLoading = true
Task {
await withTaskGroup(of: Void.self) { group in
+ // DNS → chained: email security, PTR, geolocation
group.addTask { @MainActor in
await self.runDNS(domain: target)
+ // These depend on DNS results and run in parallel after DNS
+ await withTaskGroup(of: Void.self) { postDNS in
+ postDNS.addTask { @MainActor in
+ await self.runEmailSecurity(domain: target)
+ }
+ postDNS.addTask { @MainActor in
+ await self.runReverseDNS()
+ }
+ postDNS.addTask { @MainActor in
+ await self.runIPGeolocation()
+ }
+ }
}
group.addTask { @MainActor in
await self.runSSL(domain: target)
}
+ group.addTask { @MainActor in
+ await self.runHTTPHeaders(domain: target)
+ }
+ group.addTask { @MainActor in
+ await self.runReachability(domain: target)
+ }
+ group.addTask { @MainActor in
+ await self.runRedirectChain(domain: target)
+ }
+ group.addTask { @MainActor in
+ await self.runPortScan(domain: target)
+ }
}
+ // Save history after all lookups complete so the snapshot is complete
+ saveHistoryEntry()
}
}
+ // MARK: - Lookup Methods
+
private func runDNS(domain: String) async {
do {
let sections = await DNSLookupService.lookupAll(domain: domain)
@@ -74,27 +282,157 @@ final class DomainViewModel {
sslLoading = false
}
- /// True when both lookups have finished (regardless of success/failure).
- var resultsLoaded: Bool {
- hasRun && !dnsLoading && !sslLoading
+ private func runHTTPHeaders(domain: String) async {
+ do {
+ let headers = try await HTTPHeadersService.fetch(domain: domain)
+ httpHeaders = headers
+ } catch {
+ httpHeadersError = error.localizedDescription
+ }
+ httpHeadersLoading = false
+ }
+
+ private func runReachability(domain: String) async {
+ let results = await ReachabilityService.checkAll(domain: domain)
+ reachabilityResults = results
+ reachabilityLoading = false
+ }
+
+ private func runIPGeolocation() async {
+ // Find the first A record IP
+ guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
+ let firstIP = aSection.records.first?.value else {
+ ipGeolocationError = "No A record available"
+ ipGeolocationLoading = false
+ return
+ }
+ do {
+ let geo = try await IPGeolocationService.lookup(ip: firstIP)
+ ipGeolocation = geo
+ } catch {
+ ipGeolocationError = error.localizedDescription
+ }
+ ipGeolocationLoading = false
+ }
+
+ private func runEmailSecurity(domain: String) async {
+ // Extract TXT records from already-fetched DNS sections
+ let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
+ let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
+ emailSecurity = result
+ emailSecurityLoading = false
+ }
+
+ private func runReverseDNS() async {
+ guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
+ let firstIP = aSection.records.first?.value else {
+ ptrError = "No A record available"
+ ptrLoading = false
+ return
+ }
+ let result = await ReverseDNSService.lookup(ip: firstIP)
+ ptrRecord = result
+ if result == nil {
+ ptrError = "No PTR record found"
+ }
+ ptrLoading = false
+ }
+
+ private func runRedirectChain(domain: String) async {
+ do {
+ let hops = try await RedirectChainService.trace(domain: domain)
+ redirectChain = hops
+ } catch {
+ redirectChainError = error.localizedDescription
+ }
+ redirectChainLoading = false
+ }
+
+ private func runPortScan(domain: String) async {
+ let results = await PortScanService.scanAll(domain: domain)
+ portScanResults = results
+ portScanLoading = false
}
// MARK: - Export
func exportText() -> String {
+ return Self.formatExportText(
+ domain: searchedDomain,
+ date: Date(),
+ dnsSections: dnsSections,
+ sslInfo: sslInfo,
+ sslError: sslError,
+ httpHeaders: httpHeaders,
+ httpHeadersError: httpHeadersError,
+ reachabilityResults: reachabilityResults,
+ ipGeolocation: ipGeolocation,
+ ipGeolocationError: ipGeolocationError,
+ emailSecurity: emailSecurity,
+ ptrRecord: ptrRecord,
+ redirectChain: redirectChain,
+ portScanResults: portScanResults
+ )
+ }
+
+ static func formatExportText(
+ domain: String,
+ date: Date,
+ dnsSections: [DNSSection],
+ sslInfo: SSLCertificateInfo?,
+ sslError: String? = nil,
+ httpHeaders: [HTTPHeader],
+ httpHeadersError: String? = nil,
+ reachabilityResults: [PortReachability],
+ ipGeolocation: IPGeolocation?,
+ ipGeolocationError: String? = nil,
+ emailSecurity: EmailSecurityResult? = nil,
+ ptrRecord: String? = nil,
+ redirectChain: [RedirectHop] = [],
+ portScanResults: [PortScanResult] = []
+ ) -> String {
let dateFmt = DateFormatter()
dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
- let now = dateFmt.string(from: Date())
var lines: [String] = [
"DomainDig Export",
- "Domain: \(searchedDomain)",
- "Date: \(now)",
- "",
- "DNS Records",
- "-----------"
+ "Domain: \(domain)",
+ "Date: \(dateFmt.string(from: date))",
]
+ // Reachability
+ if !reachabilityResults.isEmpty {
+ lines.append("")
+ lines.append("Reachability")
+ lines.append("------------")
+ for result in reachabilityResults {
+ if result.reachable, let ms = result.latencyMs {
+ lines.append(" Port \(result.port) \(ms)ms Reachable")
+ } else {
+ lines.append(" Port \(result.port) — Unreachable")
+ }
+ }
+ }
+
+ // Redirect Chain
+ if !redirectChain.isEmpty {
+ lines.append("")
+ lines.append("Redirect Chain")
+ lines.append("--------------")
+ if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) {
+ lines.append(" No redirects — direct connection")
+ } else {
+ for hop in redirectChain {
+ let final = hop.isFinal ? " (final)" : ""
+ lines.append(" \(hop.stepNumber) \(hop.statusCode) \(hop.url)\(final)")
+ }
+ }
+ }
+
+ // DNS
+ lines.append("")
+ lines.append("DNS Records")
+ lines.append("-----------")
for section in dnsSections {
lines.append(section.recordType.rawValue)
if let error = section.error {
@@ -107,13 +445,30 @@ final class DomainViewModel {
}
}
if !section.wildcardRecords.isEmpty {
- lines.append("*.\(searchedDomain)")
+ lines.append("*.\(domain)")
for record in section.wildcardRecords {
lines.append(" \(record.value) TTL \(record.ttl)")
}
}
}
+ // PTR
+ if let ptr = ptrRecord {
+ lines.append("PTR (Reverse DNS)")
+ lines.append(" \(ptr)")
+ }
+
+ // Email Security
+ if let email = emailSecurity {
+ lines.append("")
+ lines.append("Email Security")
+ lines.append("--------------")
+ lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")")
+ lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")")
+ lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(email.dkim.value ?? "No record found")")
+ }
+
+ // SSL
if let info = sslInfo {
let certDateFmt = DateFormatter()
certDateFmt.dateStyle = .medium
@@ -136,6 +491,59 @@ final class DomainViewModel {
lines.append("Error: \(error)")
}
+ // HTTP Headers
+ if !httpHeaders.isEmpty {
+ lines.append("")
+ lines.append("HTTP Headers")
+ lines.append("------------")
+ for header in httpHeaders {
+ lines.append(" \(header.name): \(header.value)")
+ }
+ } else if let error = httpHeadersError {
+ lines.append("")
+ lines.append("HTTP Headers")
+ lines.append("------------")
+ lines.append("Error: \(error)")
+ }
+
+ // IP Geolocation
+ if let geo = ipGeolocation {
+ lines.append("")
+ lines.append("IP Location")
+ lines.append("-----------")
+ lines.append("IP: \(geo.ip)")
+ if let org = geo.org { lines.append("Org: \(org)") }
+ let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
+ if !location.isEmpty { lines.append("Location: \(location)") }
+ if let lat = geo.latitude, let lon = geo.longitude {
+ lines.append("Coordinates: \(lat), \(lon)")
+ }
+ } else if let error = ipGeolocationError, error != "No A record available" {
+ lines.append("")
+ lines.append("IP Location")
+ lines.append("-----------")
+ lines.append("Error: \(error)")
+ }
+
+ // Open Ports
+ if !portScanResults.isEmpty {
+ lines.append("")
+ lines.append("Open Ports")
+ lines.append("----------")
+ let openPorts = portScanResults.filter { $0.open }
+ if openPorts.isEmpty {
+ lines.append(" No open ports detected")
+ } else {
+ for port in openPorts {
+ lines.append(" \(port.port) \(port.service)")
+ }
+ }
+ let closedPorts = portScanResults.filter { !$0.open }
+ if !closedPorts.isEmpty {
+ lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))")
+ }
+ }
+
return lines.joined(separator: "\n")
}