diff options
| -rw-r--r-- | DomainDig.xcodeproj/project.pbxproj | 23 | ||||
| -rw-r--r-- | DomainDig/ContentView.swift | 402 | ||||
| -rw-r--r-- | DomainDig/DNSLookupService.swift | 9 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 428 | ||||
| -rw-r--r-- | DomainDig/EmailSecurityService.swift | 61 | ||||
| -rw-r--r-- | DomainDig/HTTPHeadersService.swift | 22 | ||||
| -rw-r--r-- | DomainDig/HistoryView.swift | 496 | ||||
| -rw-r--r-- | DomainDig/IPGeolocationService.swift | 17 | ||||
| -rw-r--r-- | DomainDig/Info.plist | 10 | ||||
| -rw-r--r-- | DomainDig/Models.swift | 136 | ||||
| -rw-r--r-- | DomainDig/PortScanService.swift | 93 | ||||
| -rw-r--r-- | DomainDig/ReachabilityService.swift | 67 | ||||
| -rw-r--r-- | DomainDig/RedirectChainService.swift | 82 | ||||
| -rw-r--r-- | DomainDig/ReverseDNSService.swift | 48 | ||||
| -rw-r--r-- | DomainDig/SavedDomainsView.swift | 42 | ||||
| -rw-r--r-- | claude.md | 134 |
16 files changed, 2006 insertions, 64 deletions
diff --git a/DomainDig.xcodeproj/project.pbxproj b/DomainDig.xcodeproj/project.pbxproj index 706ba0f..f37b290 100644 --- a/DomainDig.xcodeproj/project.pbxproj +++ b/DomainDig.xcodeproj/project.pbxproj @@ -10,9 +10,22 @@ 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 8B7800682F6090E300933221 /* DomainDig */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + /* Begin PBXFileSystemSynchronizedRootGroup section */ 8B78006B2F6090E300933221 /* DomainDig */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */, + ); path = DomainDig; sourceTree = "<group>"; }; @@ -252,10 +265,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DomainDig/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -268,7 +282,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -287,10 +301,11 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 3; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = DomainDig/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; @@ -303,7 +318,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.2; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index b6588b4..940a25b 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -1,4 +1,5 @@ import SwiftUI +import MapKit struct ContentView: View { @State private var viewModel = DomainViewModel() @@ -10,21 +11,15 @@ struct ContentView: View { VStack(spacing: 0) { inputSection if viewModel.hasRun { - if viewModel.resultsLoaded { - HStack { - Spacer() - Button { - shareResults() - } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(.body)) - .foregroundStyle(.secondary) - } - } - .padding(.top, 8) - } + actionButtons + reachabilitySection + redirectChainSection dnsResultsSection + emailSecuritySection sslResultsSection + httpHeadersSection + ipGeolocationSection + portScanSection } else if !viewModel.recentSearches.isEmpty { recentSearchesSection } @@ -36,6 +31,30 @@ struct ContentView: View { .navigationTitle("DomainDig") .toolbarColorScheme(.dark, for: .navigationBar) .preferredColorScheme(.dark) + .toolbar { + ToolbarItemGroup(placement: .topBarTrailing) { + if viewModel.hasRun { + Button { + viewModel.reset() + } label: { + Image(systemName: "xmark.circle") + .foregroundStyle(.secondary) + } + } + NavigationLink { + SavedDomainsView(viewModel: viewModel) + } label: { + Image(systemName: "bookmark") + .foregroundStyle(.secondary) + } + NavigationLink { + HistoryView(viewModel: viewModel) + } label: { + Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") + .foregroundStyle(.secondary) + } + } + } } .onAppear { domainFieldFocused = true @@ -72,6 +91,31 @@ struct ContentView: View { .padding(.vertical, 16) } + // MARK: - Action Buttons (Share + Bookmark) + + private var actionButtons: some View { + HStack { + Spacer() + if viewModel.resultsLoaded { + Button { + viewModel.toggleSavedDomain() + } label: { + Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark") + .font(.system(.body)) + .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary) + } + Button { + shareResults() + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(.body)) + .foregroundStyle(.secondary) + } + } + } + .padding(.top, 8) + } + // MARK: - Recent Searches private var recentSearchesSection: some View { @@ -108,6 +152,110 @@ struct ContentView: View { .padding(.top, 8) } + // MARK: - Reachability + + private var reachabilitySection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Reachability") + + if viewModel.reachabilityLoading { + ProgressView("Checking ports…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let error = viewModel.reachabilityError { + errorLabel(error) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.reachabilityResults) { result in + HStack(spacing: 8) { + Circle() + .fill(result.reachable ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text("Port \(result.port)") + .font(.system(.caption, design: .monospaced)) + if result.reachable, let ms = result.latencyMs { + Text("\(ms)ms") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else if !result.reachable { + Text("—") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Text(result.reachable ? "Reachable" : "Unreachable") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.reachable ? .green : .red) + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 8) + } + + // MARK: - Redirect Chain + + private var redirectChainSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Redirect Chain") + + if viewModel.redirectChainLoading { + ProgressView("Tracing redirects…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let error = viewModel.redirectChainError { + errorLabel(error) + } else if viewModel.redirectChain.isEmpty { + Text("No redirect data") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(8) + } else if viewModel.redirectChain.count == 1, + let only = viewModel.redirectChain.first, + only.isFinal, !(300...399).contains(only.statusCode) { + Text("No redirects — direct connection") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.redirectChain) { hop in + HStack(alignment: .top, spacing: 6) { + Text("\(hop.stepNumber)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .trailing) + Text("\(hop.statusCode)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(width: 30, alignment: .leading) + Text(hop.url) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + if hop.isFinal { + Text("(final)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + // MARK: - DNS Results private var dnsResultsSection: some View { @@ -123,10 +271,40 @@ struct ContentView: View { } else { ForEach(viewModel.dnsSections) { section in dnsRecordSection(section) + if section.recordType == .A { + ptrRow + } } } } - .padding(.top, 8) + .padding(.top, 16) + } + + private var ptrRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text("PTR (Reverse DNS)") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if viewModel.ptrLoading { + ProgressView() + .frame(maxWidth: .infinity, alignment: .center) + .padding(4) + } else if let ptr = viewModel.ptrRecord { + Text(ptr) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } else { + Text("No PTR record found") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) } private func dnsRecordSection(_ section: DNSSection) -> some View { @@ -146,7 +324,6 @@ struct ContentView: View { dnsRecordRows(section.records) } - // Wildcard sub-section (only shown when records exist) if !section.wildcardRecords.isEmpty { Text("*.\(viewModel.searchedDomain)") .font(.system(.caption, design: .monospaced)) @@ -177,6 +354,66 @@ struct ContentView: View { } } + // MARK: - Email Security + + @State private var expandedEmailField: String? + + private var emailSecuritySection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Email Security") + + if viewModel.emailSecurityLoading { + ProgressView("Checking email records…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let error = viewModel.emailSecurityError { + errorLabel(error) + } else if let email = viewModel.emailSecurity { + VStack(alignment: .leading, spacing: 6) { + emailSecurityRow("SPF", record: email.spf) + emailSecurityRow("DMARC", record: email.dmarc) + emailSecurityRow("DKIM", record: email.dkim) + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(label) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.semibold) + .frame(width: 52, alignment: .leading) + Text(record.found ? "✓" : "✗") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(record.found ? .green : .red) + if let value = record.value { + let isExpanded = expandedEmailField == label + let displayValue = isExpanded ? value : String(value.prefix(80)) + Text(displayValue) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .lineLimit(isExpanded ? nil : 1) + .onTapGesture { + withAnimation { + expandedEmailField = isExpanded ? nil : label + } + } + } else { + Text("No record found") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + // MARK: - SSL Results private var sslResultsSection: some View { @@ -201,7 +438,6 @@ struct ContentView: View { certRow("Common Name", info.commonName) certRow("Issuer", info.issuer) - // SANs VStack(alignment: .leading, spacing: 2) { Text("SANs") .font(.system(.caption2, design: .monospaced)) @@ -235,6 +471,136 @@ struct ContentView: View { .cornerRadius(6) } + // MARK: - HTTP Headers + + private var httpHeadersSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("HTTP Headers") + + if viewModel.httpHeadersLoading { + ProgressView("Fetching headers…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let error = viewModel.httpHeadersError { + errorLabel(error) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.httpHeaders) { header in + HStack(alignment: .top, spacing: 4) { + Text(header.name + ":") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) + Text(header.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - IP Geolocation + + private var ipGeolocationSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("IP Location") + + if viewModel.ipGeolocationLoading { + ProgressView("Looking up location…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let geo = viewModel.ipGeolocation { + ipGeolocationDetail(geo) + } else if let error = viewModel.ipGeolocationError { + if error == "No A record available" { + Text("No location data available") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(8) + } else { + errorLabel(error) + } + } + } + .padding(.top, 16) + } + + private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { + VStack(alignment: .leading, spacing: 6) { + certRow("IP", geo.ip) + if let org = geo.org { + certRow("Org / ISP", org) + } + let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") + if !location.isEmpty { + certRow("Location", location) + } + if let lat = geo.latitude, let lon = geo.longitude { + let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) + Map(initialPosition: .region(MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) + ))) { + Marker(geo.ip, coordinate: coordinate) + } + .mapStyle(.standard) + .frame(height: 180) + .cornerRadius(8) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + + // MARK: - Port Scan + + private var portScanSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("Open Ports") + + if viewModel.portScanLoading { + ProgressView("Scanning ports…") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else if let error = viewModel.portScanError { + errorLabel(error) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(viewModel.portScanResults) { result in + HStack(spacing: 8) { + Circle() + .fill(result.open ? Color.green : Color(.systemGray4)) + .frame(width: 8, height: 8) + Text("\(result.port)") + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(result.service) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.open ? .primary : .secondary) + Spacer() + if result.open { + Text("Open") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.green) + } + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + // MARK: - Helpers private func sectionHeader(_ title: String) -> some View { @@ -269,8 +635,6 @@ struct ContentView: View { private func shareResults() { let text = viewModel.exportText() - - // Write to a named temp file so "Save to Files" uses a proper filename let dateFmt = DateFormatter() dateFmt.dateFormat = "yyyyMMdd_HHmmss" let timestamp = dateFmt.string(from: Date()) @@ -295,7 +659,7 @@ struct ContentView: View { } } -private extension DateFormatter { +extension DateFormatter { static let certDate: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift index 85a5275..a8f55fd 100644 --- a/DomainDig/DNSLookupService.swift +++ b/DomainDig/DNSLookupService.swift @@ -35,15 +35,16 @@ struct DNSLookupService { } } - /// Record types that support wildcard queries. - private nonisolated(unsafe) static let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT] - static func lookupAll(domain: String) async -> [DNSSection] { // Each task returns (recordType, apex records, wildcard records). typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?) + // Record types that support wildcard queries + let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT] + return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in for recordType in DNSRecordType.allCases { + let shouldQueryWildcard = wildcardTypes.contains(recordType) group.addTask { var apexRecords: [DNSRecord] = [] var wildcardRecords: [DNSRecord] = [] @@ -57,7 +58,7 @@ struct DNSLookupService { } // Wildcard query (only for applicable types, and only if apex didn't fail) - if wildcardTypes.contains(recordType) && lookupError == nil { + if shouldQueryWildcard && lookupError == nil { do { wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType) } catch { 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") } diff --git a/DomainDig/EmailSecurityService.swift b/DomainDig/EmailSecurityService.swift new file mode 100644 index 0000000..8922993 --- /dev/null +++ b/DomainDig/EmailSecurityService.swift @@ -0,0 +1,61 @@ +import Foundation + +struct EmailSecurityService { + /// Analyze email security records. SPF is parsed from existing TXT records; + /// DMARC and DKIM require additional DoH queries. + static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { + // SPF: extract from existing TXT records + let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") }) + let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value) + + // DMARC and DKIM queries in parallel + async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)") + async let dkimResult = queryDKIM(domain: domain) + + let dmarcValue = await dmarcResult + let dkimValue = await dkimResult + + let dmarc = EmailSecurityRecord( + found: dmarcValue != nil, + value: dmarcValue + ) + let dkim = EmailSecurityRecord( + found: dkimValue != nil, + value: dkimValue + ) + + return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim) + } + + /// Query a TXT record for the given subdomain via DoH. + private static func queryTXT(subdomain: String) async -> String? { + do { + let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT) + return records.first?.value + } catch { + return nil + } + } + + /// Try common DKIM selectors and return the first found. + private static func queryDKIM(domain: String) async -> String? { + let selectors = ["default", "google", "mail"] + return await withTaskGroup(of: (Int, String?).self, returning: String?.self) { group in + for (index, selector) in selectors.enumerated() { + group.addTask { + let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)") + return (index, value) + } + } + + var results: [(Int, String?)] = [] + for await result in group { + results.append(result) + } + // Return the first (by selector order) that has a value + return results + .sorted { $0.0 < $1.0 } + .first(where: { $0.1 != nil })?.1 + } + } +} diff --git a/DomainDig/HTTPHeadersService.swift b/DomainDig/HTTPHeadersService.swift new file mode 100644 index 0000000..a297165 --- /dev/null +++ b/DomainDig/HTTPHeadersService.swift @@ -0,0 +1,22 @@ +import Foundation + +struct HTTPHeadersService { + static func fetch(domain: String) async throws -> [HTTPHeader] { + let url = URL(string: "https://\(domain)")! + var request = URLRequest(url: url, timeoutInterval: 10) + request.httpMethod = "HEAD" + + let (_, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + return httpResponse.allHeaderFields.compactMap { key, value in + guard let name = key as? String, + let val = value as? String else { return nil } + return HTTPHeader(name: name, value: val) + } + .sorted { $0.name.lowercased() < $1.name.lowercased() } + } +} diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift new file mode 100644 index 0000000..b64ebf3 --- /dev/null +++ b/DomainDig/HistoryView.swift @@ -0,0 +1,496 @@ +import SwiftUI +import MapKit + +struct HistoryView: View { + @Bindable var viewModel: DomainViewModel + @Environment(\.dismiss) private var dismiss + + private let dateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + var body: some View { + List { + if viewModel.history.isEmpty { + Text("No lookup history") + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + ForEach(viewModel.history) { entry in + NavigationLink { + HistoryDetailView(entry: entry) + } label: { + VStack(alignment: .leading, spacing: 2) { + Text(entry.domain) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + Text(dateFmt.string(from: entry.timestamp)) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .onDelete { offsets in + viewModel.removeHistoryEntries(at: offsets) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("History") + .toolbar { + if !viewModel.history.isEmpty { + EditButton() + } + } + .preferredColorScheme(.dark) + } +} + +// MARK: - History Detail View (Read-Only Cached Results) + +struct HistoryDetailView: View { + let entry: HistoryEntry + + private let dateFmt: DateFormatter = { + let f = DateFormatter() + f.dateStyle = .medium + f.timeStyle = .short + return f + }() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + cachedBanner + reachabilitySection + redirectChainSection + dnsSection + emailSecuritySection + sslSection + httpHeadersSection + ipGeolocationSection + portScanSection + } + .padding(.horizontal) + .padding(.bottom, 32) + } + .background(Color.black) + .navigationTitle(entry.domain) + .preferredColorScheme(.dark) + } + + // MARK: - Cached Banner + + private var cachedBanner: some View { + HStack(spacing: 6) { + Image(systemName: "archivebox") + .font(.caption) + Text("Cached result from \(dateFmt.string(from: entry.timestamp))") + .font(.system(.caption, design: .monospaced)) + } + .foregroundStyle(.secondary) + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6).opacity(0.3)) + .cornerRadius(6) + .padding(.vertical, 12) + } + + // MARK: - Reachability + + private var reachabilitySection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.reachabilityResults.isEmpty { + sectionHeader("Reachability") + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.reachabilityResults) { result in + HStack(spacing: 8) { + Circle() + .fill(result.reachable ? Color.green : Color.red) + .frame(width: 8, height: 8) + Text("Port \(result.port)") + .font(.system(.caption, design: .monospaced)) + if result.reachable, let ms = result.latencyMs { + Text("\(ms)ms") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else if !result.reachable { + Text("—") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + Spacer() + Text(result.reachable ? "Reachable" : "Unreachable") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.reachable ? .green : .red) + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 8) + } + + // MARK: - Redirect Chain + + private var redirectChainSection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.redirectChain.isEmpty { + sectionHeader("Redirect Chain") + if entry.redirectChain.count == 1, + let only = entry.redirectChain.first, + only.isFinal, !(300...399).contains(only.statusCode) { + Text("No redirects — direct connection") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } else { + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.redirectChain) { hop in + HStack(alignment: .top, spacing: 6) { + Text("\(hop.stepNumber)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .trailing) + Text("\(hop.statusCode)") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(width: 30, alignment: .leading) + Text(hop.url) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + if hop.isFinal { + Text("(final)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + } + .padding(.top, 16) + } + + // MARK: - DNS + + private var dnsSection: some View { + VStack(alignment: .leading, spacing: 12) { + sectionHeader("DNS Records") + ForEach(entry.dnsSections) { section in + VStack(alignment: .leading, spacing: 4) { + Text(section.recordType.rawValue) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if let error = section.error { + errorLabel(error) + } else if section.records.isEmpty { + Text("No records found") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } else { + recordRows(section.records) + } + + if !section.wildcardRecords.isEmpty { + Text("*.\(entry.domain)") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.medium) + .foregroundStyle(.cyan.opacity(0.7)) + .padding(.top, 4) + recordRows(section.wildcardRecords) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + + if section.recordType == .A { + VStack(alignment: .leading, spacing: 4) { + Text("PTR (Reverse DNS)") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if let ptr = entry.ptrRecord { + Text(ptr) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } else { + Text("No PTR record found") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + } + .padding(.top, 16) + } + + // MARK: - Email Security + + @State private var expandedEmailField: String? + + private var emailSecuritySection: some View { + VStack(alignment: .leading, spacing: 12) { + if let email = entry.emailSecurity { + sectionHeader("Email Security") + VStack(alignment: .leading, spacing: 6) { + historyEmailRow("SPF", record: email.spf) + historyEmailRow("DMARC", record: email.dmarc) + historyEmailRow("DKIM", record: email.dkim) + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(label) + .font(.system(.caption, design: .monospaced)) + .fontWeight(.semibold) + .frame(width: 52, alignment: .leading) + Text(record.found ? "✓" : "✗") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(record.found ? .green : .red) + if let value = record.value { + let isExpanded = expandedEmailField == label + let displayValue = isExpanded ? value : String(value.prefix(80)) + Text(displayValue) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + .lineLimit(isExpanded ? nil : 1) + .onTapGesture { + withAnimation { + expandedEmailField = isExpanded ? nil : label + } + } + } else { + Text("No record found") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + } + + // MARK: - SSL + + private var sslSection: some View { + VStack(alignment: .leading, spacing: 12) { + if let info = entry.sslInfo { + sectionHeader("SSL / TLS Certificate") + VStack(alignment: .leading, spacing: 8) { + labelRow("Common Name", info.commonName) + labelRow("Issuer", info.issuer) + + VStack(alignment: .leading, spacing: 2) { + Text("SANs") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + ForEach(info.subjectAltNames, id: \.self) { san in + Text(san) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom)) + labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil)) + + HStack { + Text("Days Until Expiry") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Spacer() + Text("\(info.daysUntilExpiry)") + .font(.system(.caption, design: .monospaced)) + .fontWeight(.bold) + .foregroundStyle(expiryColor(info.daysUntilExpiry)) + } + + labelRow("Chain Depth", "\(info.chainDepth)") + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - HTTP Headers + + private var httpHeadersSection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.httpHeaders.isEmpty { + sectionHeader("HTTP Headers") + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.httpHeaders) { header in + HStack(alignment: .top, spacing: 4) { + Text(header.name + ":") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) + Text(header.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - IP Geolocation + + private var ipGeolocationSection: some View { + VStack(alignment: .leading, spacing: 12) { + if let geo = entry.ipGeolocation { + sectionHeader("IP Location") + VStack(alignment: .leading, spacing: 6) { + labelRow("IP", geo.ip) + if let org = geo.org { + labelRow("Org / ISP", org) + } + let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") + if !location.isEmpty { + labelRow("Location", location) + } + if let lat = geo.latitude, let lon = geo.longitude { + let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) + Map(initialPosition: .region(MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) + ))) { + Marker(geo.ip, coordinate: coordinate) + } + .mapStyle(.standard) + .frame(height: 180) + .cornerRadius(8) + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - Port Scan + + private var portScanSection: some View { + VStack(alignment: .leading, spacing: 12) { + if !entry.portScanResults.isEmpty { + sectionHeader("Open Ports") + VStack(alignment: .leading, spacing: 4) { + ForEach(entry.portScanResults) { result in + HStack(spacing: 8) { + Circle() + .fill(result.open ? Color.green : Color(.systemGray4)) + .frame(width: 8, height: 8) + Text("\(result.port)") + .font(.system(.caption, design: .monospaced)) + .frame(width: 44, alignment: .leading) + Text(result.service) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(result.open ? .primary : .secondary) + Spacer() + if result.open { + Text("Open") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.green) + } + } + } + } + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) + } + } + .padding(.top, 16) + } + + // MARK: - Helpers + + private func sectionHeader(_ title: String) -> some View { + Text(title) + .font(.system(.headline, design: .default)) + .foregroundStyle(.white) + } + + private func labelRow(_ label: String, _ value: String) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(value) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } + + private func recordRows(_ records: [DNSRecord]) -> some View { + ForEach(records) { record in + HStack(alignment: .top) { + Text(record.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + Spacer() + Text("TTL \(record.ttl)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } + + private func errorLabel(_ message: String) -> some View { + Label(message, systemImage: "exclamationmark.triangle.fill") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.red) + .padding(8) + } + + private func expiryColor(_ days: Int) -> Color { + if days < 30 { return .red } + if days < 60 { return .yellow } + return .green + } +} diff --git a/DomainDig/IPGeolocationService.swift b/DomainDig/IPGeolocationService.swift new file mode 100644 index 0000000..6c60295 --- /dev/null +++ b/DomainDig/IPGeolocationService.swift @@ -0,0 +1,17 @@ +import Foundation + +struct IPGeolocationService { + static func lookup(ip: String) async throws -> IPGeolocation { + let url = URL(string: "https://ipapi.co/\(ip)/json/")! + let request = URLRequest(url: url, timeoutInterval: 10) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + return try JSONDecoder().decode(IPGeolocation.self, from: data) + } +} diff --git a/DomainDig/Info.plist b/DomainDig/Info.plist new file mode 100644 index 0000000..e397175 --- /dev/null +++ b/DomainDig/Info.plist @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>Allow Arbitrary Loads (or NSAllowsArbitraryLoads) </key> + <true/> + <key>App Transport Security Settings (or NSAppTransportSecurity)</key> + <dict/> +</dict> +</plist> diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 53b3c39..88bcfa9 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -2,7 +2,7 @@ import Foundation // MARK: - DNS Models -enum DNSRecordType: String, CaseIterable { +enum DNSRecordType: String, CaseIterable, Codable { case A case AAAA case MX @@ -22,14 +22,14 @@ enum DNSRecordType: String, CaseIterable { } } -struct DNSRecord: Identifiable { - let id = UUID() +struct DNSRecord: Identifiable, Codable { + var id = UUID() let value: String let ttl: Int } -struct DNSSection: Identifiable { - let id = UUID() +struct DNSSection: Identifiable, Codable { + var id = UUID() let recordType: DNSRecordType var records: [DNSRecord] var wildcardRecords: [DNSRecord] = [] @@ -38,7 +38,7 @@ struct DNSSection: Identifiable { // MARK: - SSL Models -struct SSLCertificateInfo { +struct SSLCertificateInfo: Codable { let commonName: String let subjectAltNames: [String] let issuer: String @@ -48,6 +48,130 @@ struct SSLCertificateInfo { let chainDepth: Int } +// MARK: - HTTP Headers Models + +struct HTTPHeader: Identifiable, Codable { + var id = UUID() + let name: String + let value: String + + static let securityHeaders: Set<String> = [ + "strict-transport-security", + "x-frame-options", + "x-content-type-options", + "content-security-policy", + "referrer-policy" + ] + + var isSecurityHeader: Bool { + Self.securityHeaders.contains(name.lowercased()) + } +} + +// MARK: - Reachability Models + +struct PortReachability: Identifiable, Codable { + var id = UUID() + let port: UInt16 + let reachable: Bool + let latencyMs: Int? +} + +// MARK: - IP Geolocation Models + +struct IPGeolocation: Codable { + let ip: String + let city: String? + let region: String? + let country_name: String? + let org: String? + let latitude: Double? + let longitude: Double? +} + +// MARK: - Email Security Models + +struct EmailSecurityResult: Codable { + let spf: EmailSecurityRecord + let dmarc: EmailSecurityRecord + let dkim: EmailSecurityRecord +} + +struct EmailSecurityRecord: Codable { + let found: Bool + let value: String? +} + +// MARK: - Redirect Chain Models + +struct RedirectHop: Identifiable, Codable { + var id = UUID() + let stepNumber: Int + let statusCode: Int + let url: String + let isFinal: Bool +} + +// MARK: - Port Scan Models + +struct PortScanResult: Identifiable, Codable { + var id = UUID() + let port: UInt16 + let service: String + let open: Bool +} + +// MARK: - History Models + +struct HistoryEntry: Identifiable, Codable { + var id = UUID() + let domain: String + let timestamp: Date + let dnsSections: [DNSSection] + let sslInfo: SSLCertificateInfo? + let httpHeaders: [HTTPHeader] + let reachabilityResults: [PortReachability] + let ipGeolocation: IPGeolocation? + var emailSecurity: EmailSecurityResult? + var ptrRecord: String? + var redirectChain: [RedirectHop] + var portScanResults: [PortScanResult] + + init(domain: String, timestamp: Date, dnsSections: [DNSSection], + sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], + reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, + emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil, + redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = []) { + self.domain = domain + self.timestamp = timestamp + self.dnsSections = dnsSections + self.sslInfo = sslInfo + self.httpHeaders = httpHeaders + self.reachabilityResults = reachabilityResults + self.ipGeolocation = ipGeolocation + self.emailSecurity = emailSecurity + self.ptrRecord = ptrRecord + self.redirectChain = redirectChain + self.portScanResults = portScanResults + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + domain = try container.decode(String.self, forKey: .domain) + timestamp = try container.decode(Date.self, forKey: .timestamp) + dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections) + sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo) + httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders) + reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults) + ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation) + emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity) + ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord) + redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] + portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] + } +} + // MARK: - Cloudflare DNS-over-HTTPS Response struct CloudflareDNSResponse: Decodable { diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift new file mode 100644 index 0000000..63d0571 --- /dev/null +++ b/DomainDig/PortScanService.swift @@ -0,0 +1,93 @@ +import Foundation +import Network + +struct PortScanService { + struct PortInfo: Sendable { + let port: UInt16 + let service: String + } + + static let ports: [PortInfo] = [ + PortInfo(port: 21, service: "FTP"), + PortInfo(port: 22, service: "SSH"), + PortInfo(port: 25, service: "SMTP"), + PortInfo(port: 80, service: "HTTP"), + PortInfo(port: 443, service: "HTTPS"), + PortInfo(port: 587, service: "SMTP (TLS)"), + PortInfo(port: 3306, service: "MySQL"), + PortInfo(port: 5432, service: "PostgreSQL"), + PortInfo(port: 8080, service: "HTTP Alt"), + PortInfo(port: 8443, service: "HTTPS Alt"), + ] + + static func scanAll(domain: String) async -> [PortScanResult] { + await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in + for info in ports { + group.addTask { + let open = await probe(domain: domain, port: info.port) + return PortScanResult(port: info.port, service: info.service, open: open) + } + } + + var results: [PortScanResult] = [] + for await result in group { + results.append(result) + } + + // Sort by port number + return results.sorted { $0.port < $1.port } + } + } + + private static func probe(domain: String, port: UInt16) async -> Bool { + await withCheckedContinuation { continuation in + let host = NWEndpoint.Host(domain) + let nwPort = NWEndpoint.Port(rawValue: port)! + let connection = NWConnection(host: host, port: nwPort, using: .tcp) + let context = ProbeContext(connection: connection, continuation: continuation) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + context.finish(open: true) + case .failed, .cancelled: + context.finish(open: false) + default: + break + } + } + + let queue = DispatchQueue(label: "portscan.\(port)") + connection.start(queue: queue) + + queue.asyncAfter(deadline: .now() + 3) { + context.finish(open: false) + } + } + } +} + +private final class ProbeContext: @unchecked Sendable { + private let connection: NWConnection + private let continuation: CheckedContinuation<Bool, Never> + private let lock = NSLock() + private nonisolated(unsafe) var resumed = false + + init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) { + self.connection = connection + self.continuation = continuation + } + + nonisolated func finish(open: Bool) { + lock.lock() + guard !resumed else { + lock.unlock() + return + } + resumed = true + lock.unlock() + + connection.cancel() + continuation.resume(returning: open) + } +} diff --git a/DomainDig/ReachabilityService.swift b/DomainDig/ReachabilityService.swift new file mode 100644 index 0000000..0bb30ae --- /dev/null +++ b/DomainDig/ReachabilityService.swift @@ -0,0 +1,67 @@ +import Foundation +import Network + +struct ReachabilityService { + static func check(domain: String, port: UInt16) async -> PortReachability { + await withCheckedContinuation { continuation in + let host = NWEndpoint.Host(domain) + let nwPort = NWEndpoint.Port(rawValue: port)! + let connection = NWConnection(host: host, port: nwPort, using: .tcp) + let context = ConnectionContext(port: port, connection: connection, continuation: continuation) + + connection.stateUpdateHandler = { state in + switch state { + case .ready: + context.finish(reachable: true) + case .failed, .cancelled: + context.finish(reachable: false) + default: + break + } + } + + let queue = DispatchQueue(label: "reachability.\(port)") + connection.start(queue: queue) + + queue.asyncAfter(deadline: .now() + 5) { + context.finish(reachable: false) + } + } + } + + static func checkAll(domain: String) async -> [PortReachability] { + async let port443 = check(domain: domain, port: 443) + async let port80 = check(domain: domain, port: 80) + return await [port443, port80] + } +} + +private final class ConnectionContext: @unchecked Sendable { + private let port: UInt16 + private let connection: NWConnection + private let continuation: CheckedContinuation<PortReachability, Never> + private let start = CFAbsoluteTimeGetCurrent() + private let lock = NSLock() + private nonisolated(unsafe) var resumed = false + + init(port: UInt16, connection: NWConnection, continuation: CheckedContinuation<PortReachability, Never>) { + self.port = port + self.connection = connection + self.continuation = continuation + } + + nonisolated func finish(reachable: Bool) { + lock.lock() + guard !resumed else { + lock.unlock() + return + } + resumed = true + lock.unlock() + + let elapsed = CFAbsoluteTimeGetCurrent() - start + let ms = reachable ? Int(elapsed * 1000) : nil + connection.cancel() + continuation.resume(returning: PortReachability(port: port, reachable: reachable, latencyMs: ms)) + } +} diff --git a/DomainDig/RedirectChainService.swift b/DomainDig/RedirectChainService.swift new file mode 100644 index 0000000..7a23a5e --- /dev/null +++ b/DomainDig/RedirectChainService.swift @@ -0,0 +1,82 @@ +import Foundation + +struct RedirectChainService { + static func trace(domain: String) async throws -> [RedirectHop] { + // Try HTTPS first (avoids ATS issues), fall back to HTTP if it fails entirely + do { + return try await followChain(startingURL: URL(string: "https://\(domain)")!) + } catch { + return try await followChain(startingURL: URL(string: "http://\(domain)")!) + } + } + + private static func followChain(startingURL: URL) async throws -> [RedirectHop] { + let delegate = NoRedirectDelegate() + let session = URLSession( + configuration: .ephemeral, + delegate: delegate, + delegateQueue: nil + ) + defer { session.invalidateAndCancel() } + + var hops: [RedirectHop] = [] + var currentURL = startingURL + let maxRedirects = 10 + + for step in 1...maxRedirects + 1 { + var request = URLRequest(url: currentURL, timeoutInterval: 10) + request.httpMethod = "GET" + + let (_, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw URLError(.badServerResponse) + } + + let statusCode = httpResponse.statusCode + let isRedirect = (300...399).contains(statusCode) + + if isRedirect, let location = httpResponse.value(forHTTPHeaderField: "Location") { + hops.append(RedirectHop( + stepNumber: step, + statusCode: statusCode, + url: currentURL.absoluteString, + isFinal: false + )) + + // Resolve relative redirects + if let nextURL = URL(string: location, relativeTo: currentURL)?.absoluteURL { + currentURL = nextURL + } else { + break + } + + if step > maxRedirects { break } + } else { + // Non-redirect — this is the final destination + hops.append(RedirectHop( + stepNumber: step, + statusCode: statusCode, + url: currentURL.absoluteString, + isFinal: true + )) + break + } + } + + return hops + } +} + +private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable { + func urlSession( + _ session: URLSession, + task: URLSessionTask, + willPerformHTTPRedirection response: HTTPURLResponse, + newRequest request: URLRequest, + completionHandler: @escaping (URLRequest?) -> Void + ) { + // Don't follow redirects automatically — return nil to stop + completionHandler(nil) + } +} diff --git a/DomainDig/ReverseDNSService.swift b/DomainDig/ReverseDNSService.swift new file mode 100644 index 0000000..caab0f7 --- /dev/null +++ b/DomainDig/ReverseDNSService.swift @@ -0,0 +1,48 @@ +import Foundation + +struct ReverseDNSService { + /// Look up the PTR record for an IPv4 address via Cloudflare DoH. + static func lookup(ip: String) async -> String? { + let octets = ip.split(separator: ".") + guard octets.count == 4 else { return nil } + + let reversed = octets.reversed().joined(separator: ".") + let ptrDomain = "\(reversed).in-addr.arpa" + + // PTR record type = 12 + do { + let records = try await lookupPTR(domain: ptrDomain) + return records.first + } catch { + return nil + } + } + + private static func lookupPTR(domain: String) async throws -> [String] { + var components = URLComponents(string: "https://cloudflare-dns.com/dns-query")! + components.queryItems = [ + URLQueryItem(name: "name", value: domain), + URLQueryItem(name: "type", value: "12") // PTR + ] + + var request = URLRequest(url: components.url!) + request.setValue("application/dns-json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + throw URLError(.badServerResponse) + } + + let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data) + + guard let answers = dnsResponse.Answer else { + return [] + } + + return answers + .filter { $0.type == 12 } + .map { $0.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } + } +} diff --git a/DomainDig/SavedDomainsView.swift b/DomainDig/SavedDomainsView.swift new file mode 100644 index 0000000..f407d78 --- /dev/null +++ b/DomainDig/SavedDomainsView.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct SavedDomainsView: View { + @Bindable var viewModel: DomainViewModel + @Environment(\.dismiss) private var dismiss + + var body: some View { + List { + if viewModel.savedDomains.isEmpty { + Text("No saved domains") + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.secondary) + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } else { + ForEach(viewModel.savedDomains, id: \.self) { domain in + Button { + viewModel.domain = domain + dismiss() + viewModel.run() + } label: { + Text(domain) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(.primary) + } + .listRowBackground(Color(.systemGray6).opacity(0.5)) + } + .onDelete { offsets in + viewModel.removeSavedDomains(at: offsets) + } + } + } + .scrollContentBackground(.hidden) + .background(Color.black) + .navigationTitle("Saved Domains") + .toolbar { + if !viewModel.savedDomains.isEmpty { + EditButton() + } + } + .preferredColorScheme(.dark) + } +} @@ -1,9 +1,11 @@ # DomainDig – Project Context ## What this is -An iOS utility app for querying DNS records and inspecting SSL/TLS certificates -for any domain. Designed for developers, sysadmins, and anyone who manages or -troubleshoots domains and servers. +An iOS utility app for querying DNS records, inspecting SSL/TLS certificates, +checking HTTP headers, measuring TCP reachability, geolocating IP addresses, +analyzing email security records, tracing redirect chains, performing reverse +DNS lookups, and scanning common ports for any domain. Designed for developers, +sysadmins, and anyone who manages or troubleshoots domains and servers. ## Target user Technically literate adults — developers, IT/sysadmin, homelab enthusiasts. @@ -18,6 +20,8 @@ clean and information-dense, not hand-holding. - No onboarding, no tutorials, no splash screens - Fast — results should appear as soon as they're available, not after all lookups complete +- Each result section loads independently with its own ProgressView +- Errors shown inline per-section, never as global alerts ## Features @@ -35,6 +39,10 @@ Display each record type in its own section. Show TTL alongside each result. If a record type returns no results, show "No records found" for that type rather than hiding the section entirely. +After the apex query, also queries `*.{domain}` for A, AAAA, MX, and TXT. +Wildcard results are shown as a sub-section beneath each record type, labelled +with `*.{domain}`. Hidden if no wildcard records are returned. + ### SSL/TLS Certificate Check Connect to the domain on port 443 via URLSession and inspect the server's certificate chain using URLSession delegate methods. Display: @@ -45,19 +53,63 @@ certificate chain using URLSession delegate methods. Display: - Days until expiry — highlight in red if under 30 days, yellow if under 60 - Certificate chain depth +### HTTP Headers Check +Fire a HEAD request to `https://{domain}` and display all response headers. +Header names in cyan, values in primary. Security-relevant headers highlighted +in yellow: `strict-transport-security`, `x-frame-options`, +`x-content-type-options`, `content-security-policy`, `referrer-policy`. +Service: `HTTPHeadersService.swift`. + +### Reachability / TCP Latency +Use `NWConnection` (Network framework) to attempt TCP connections on ports 443 +and 80. Measure time to `.ready` state. Display green/red dot, latency in ms, +and reachable/unreachable status. Timeout after 5 seconds. +Service: `ReachabilityService.swift`. + +### IP Geolocation with Map +After DNS A record resolves, take the first IP and query +`https://ipapi.co/{ip}/json/` for country, region, city, org, lat/lon. +Display in an "IP Location" section with a SwiftUI `Map` (MapKit) centered on +the coordinates with a `Marker`. Map height 180pt, `.standard` style. +Service: `IPGeolocationService.swift`. + +### Email Security Analysis +Parse SPF from existing TXT records (no extra query). Query `_dmarc.{domain}` +for DMARC and try common DKIM selectors (`default`, `google`, `mail`) via DoH. +Display green checkmark if found, red ✗ if not. Full record values truncated +to 80 chars with tap-to-expand. Triggered after DNS completes. +Service: `EmailSecurityService.swift`. + +### Reverse DNS / PTR Lookup +After DNS A record resolves, construct reverse DNS name (reversed octets + +`.in-addr.arpa`) and query PTR record via Cloudflare DoH. Displayed inline +in the DNS Records section below the A record sub-section. +Service: `ReverseDNSService.swift`. + +### Redirect Chain +Fire HTTP request to `http://{domain}` with redirects disabled. Follow up to +10 redirects manually, recording each hop's URL and status code. Display step +number, status code in cyan, URL, and "(final)" on the last hop. Shows +"No redirects — direct connection" if the first request returns 200. +Service: `RedirectChainService.swift`. + +### Common Port Scanner +Probe 10 common ports (21/FTP, 22/SSH, 25/SMTP, 80/HTTP, 443/HTTPS, +587/SMTP-TLS, 3306/MySQL, 5432/PostgreSQL, 8080/HTTP-Alt, 8443/HTTPS-Alt) +using `NWConnection` with 3-second timeout. All probes run concurrently. +Green dot for open, grey dot for closed. Closed is expected — no error shown. +Service: `PortScanService.swift`. + ### Results layout - Domain input at the top — large text field, keyboard shows on launch -- Run button to trigger both DNS and SSL lookups simultaneously -- DNS and SSL results displayed in clearly separated sections below -- Each section loads independently — don't block SSL results waiting for DNS - or vice versa - -## Technical constraints -- SwiftUI, iOS only -- Fully offline except for DNS-over-HTTPS requests and SSL connections -- No accounts, no analytics, no ads -- No third-party dependencies — URLSession and Network framework only -- Targets latest iOS +- Run button to trigger all lookups simultaneously +- Results displayed in clearly separated sections: + Reachability → Redirect Chain → DNS Records (with PTR inline) → + Email Security → SSL/TLS Certificate → HTTP Headers → IP Location → + Open Ports +- Each section loads independently — don't block one section waiting for another +- Email security, PTR, and IP geolocation are chained after DNS; all others + run in parallel ### Recent searches Store the last 20 searched domains locally using UserDefaults. Display them as @@ -65,15 +117,55 @@ a tappable list below the text field when no results are showing. Tapping a recent domain populates the text field and runs the lookup immediately. Include a "Clear" button to wipe history. Most recent at the top. +### Saved domains +Bookmark button (SF Symbol: `bookmark` / `bookmark.fill`) in the results +toolbar area, next to the share button. Tapping saves/unsaves the current +domain. Filled icon when saved. Saved domains viewable from a toolbar button +that pushes to `SavedDomainsView` — a list of saved domains, tappable to run +lookups, with swipe-to-delete and an Edit button for bulk deletion. +Persisted in UserDefaults under key `savedDomains`. + +### Lookup history with cached results +After each successful lookup, a snapshot of all results (DNS, SSL, HTTP headers, +reachability, geolocation, email security, PTR, redirect chain, port scan) is +saved to history in UserDefaults as JSON. Capped at 50 entries. `HistoryView` +shows past lookups with domain and timestamp. Tapping shows full cached results +in `HistoryDetailView` using the same layout, labelled as cached with the +original timestamp. Model: `HistoryEntry` (Codable). + ### Share / export A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the -results area. Formats the full DNS and SSL results as plain text and presents -the iOS share sheet via ShareLink or UIActivityViewController. The export -should include the domain, timestamp, all DNS records with TTLs, and all SSL -cert fields. +results area. Formats the full results as plain text and presents the iOS share +sheet via UIActivityViewController. The export includes: domain, timestamp, +reachability, redirect chain, all DNS records with TTLs and PTR, email security +records, SSL cert fields, HTTP headers, IP geolocation data, and port scan +results. + +## Technical constraints +- SwiftUI, iOS only +- Fully offline except for network requests (DNS-over-HTTPS, SSL, HTTP HEAD, + TCP connections, geolocation API) +- No accounts, no analytics, no ads +- No third-party dependencies — URLSession, Network, MapKit only +- Targets latest iOS + +## Architecture +- `Models.swift` — All data models (DNS, SSL, HTTP headers, reachability, + geolocation, history entry), all Codable for persistence +- `DomainViewModel.swift` — `@Observable` view model orchestrating all lookups, + managing state, history, saved domains, recent searches, and export +- `ContentView.swift` — Main screen with input, all result sections, toolbar +- `SavedDomainsView.swift` — Saved domains list with edit/delete +- `HistoryView.swift` — History list + `HistoryDetailView` for cached results +- Services: `DNSLookupService`, `SSLCheckService`, `HTTPHeadersService`, + `ReachabilityService`, `IPGeolocationService`, `EmailSecurityService`, + `ReverseDNSService`, `RedirectChainService`, `PortScanService` ## What good looks like A developer pastes a domain, taps run, and within a couple of seconds sees a -clean breakdown of every DNS record type and the full SSL cert status. It should -feel like a native, polished version of running `dig` and `openssl s_client` -from the terminal. +clean breakdown of TCP reachability, redirect chain, every DNS record type with +reverse DNS, email security posture, the full SSL cert status, HTTP response +headers with security headers highlighted, IP geolocation with a map, and a +port scan of common services. It should feel like a native, polished version of +running `dig`, `openssl s_client`, `curl -I`, `nmap`, and `whois` from the +terminal. |
