summaryrefslogtreecommitdiff
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
parente11b7d29a6aa9e52f63fcb35ee7ed9127850fffe (diff)
downloaddomain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.tar.gz
domain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.tar.bz2
domain-dig-376e7b55b4a0c4f54faa0c2828dea4fd17dff288.zip
realigning commits
-rw-r--r--DomainDig.xcodeproj/project.pbxproj23
-rw-r--r--DomainDig/ContentView.swift402
-rw-r--r--DomainDig/DNSLookupService.swift9
-rw-r--r--DomainDig/DomainViewModel.swift428
-rw-r--r--DomainDig/EmailSecurityService.swift61
-rw-r--r--DomainDig/HTTPHeadersService.swift22
-rw-r--r--DomainDig/HistoryView.swift496
-rw-r--r--DomainDig/IPGeolocationService.swift17
-rw-r--r--DomainDig/Info.plist10
-rw-r--r--DomainDig/Models.swift136
-rw-r--r--DomainDig/PortScanService.swift93
-rw-r--r--DomainDig/ReachabilityService.swift67
-rw-r--r--DomainDig/RedirectChainService.swift82
-rw-r--r--DomainDig/ReverseDNSService.swift48
-rw-r--r--DomainDig/SavedDomainsView.swift42
-rw-r--r--claude.md134
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 NSAllows​Arbitrary​Loads) </key>
+ <true/>
+ <key>App ​Transport ​Security ​Settings (or NSApp​Transport​Security)</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)
+ }
+}
diff --git a/claude.md b/claude.md
index 4a90660..bee59f1 100644
--- a/claude.md
+++ b/claude.md
@@ -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.