Commit 376e7b55b4

376e7b55b4a0c4f54faa0c2828dea4fd17dff288

parent: e11b7d29a6

Verified · cmc

cmc <hello@cleberg.net> · 2026-03-18 16:59 UTC

realigning commits

Layout: unified · split

DomainDig.xcodeproj/project.pbxproj +19 −4
@@ -10,9 +10,22 @@
1010 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
1111/* End PBXFileReference section */
1212
13/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
14 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */ = {
15 isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
16 membershipExceptions = (
17 Info.plist,
18 );
19 target = 8B7800682F6090E300933221 /* DomainDig */;
20 };
21/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
22
1323/* Begin PBXFileSystemSynchronizedRootGroup section */
1424 8B78006B2F6090E300933221 /* DomainDig */ = {
1525 isa = PBXFileSystemSynchronizedRootGroup;
26 exceptions = (
27 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
28 );
1629 path = DomainDig;
1730 sourceTree = "<group>";
1831 };
@@ -252,10 +265,11 @@
252265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
253266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
254267 CODE_SIGN_STYLE = Automatic;
255 CURRENT_PROJECT_VERSION = 1;
268 CURRENT_PROJECT_VERSION = 3;
256269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
257270 ENABLE_PREVIEWS = YES;
258271 GENERATE_INFOPLIST_FILE = YES;
272 INFOPLIST_FILE = DomainDig/Info.plist;
259273 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
260274 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
261275 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -268,7 +282,7 @@
268282 "$(inherited)",
269283 "@executable_path/Frameworks",
270284 );
271 MARKETING_VERSION = 1.0;
285 MARKETING_VERSION = 1.2;
272286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
273287 PRODUCT_NAME = "$(TARGET_NAME)";
274288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -287,10 +301,11 @@
287301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
288302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
289303 CODE_SIGN_STYLE = Automatic;
290 CURRENT_PROJECT_VERSION = 1;
304 CURRENT_PROJECT_VERSION = 3;
291305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
292306 ENABLE_PREVIEWS = YES;
293307 GENERATE_INFOPLIST_FILE = YES;
308 INFOPLIST_FILE = DomainDig/Info.plist;
294309 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
295310 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
296311 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -303,7 +318,7 @@
303318 "$(inherited)",
304319 "@executable_path/Frameworks",
305320 );
306 MARKETING_VERSION = 1.0;
321 MARKETING_VERSION = 1.2;
307322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
308323 PRODUCT_NAME = "$(TARGET_NAME)";
309324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +383 −19
@@ -1,4 +1,5 @@
11import SwiftUI
2import MapKit
23
34struct ContentView: View {
45 @State private var viewModel = DomainViewModel()
@@ -10,21 +11,15 @@ struct ContentView: View {
1011 VStack(spacing: 0) {
1112 inputSection
1213 if viewModel.hasRun {
13 if viewModel.resultsLoaded {
14 HStack {
15 Spacer()
16 Button {
17 shareResults()
18 } label: {
19 Image(systemName: "square.and.arrow.up")
20 .font(.system(.body))
21 .foregroundStyle(.secondary)
22 }
23 }
24 .padding(.top, 8)
25 }
14 actionButtons
15 reachabilitySection
16 redirectChainSection
2617 dnsResultsSection
18 emailSecuritySection
2719 sslResultsSection
20 httpHeadersSection
21 ipGeolocationSection
22 portScanSection
2823 } else if !viewModel.recentSearches.isEmpty {
2924 recentSearchesSection
3025 }
@@ -36,6 +31,30 @@ struct ContentView: View {
3631 .navigationTitle("DomainDig")
3732 .toolbarColorScheme(.dark, for: .navigationBar)
3833 .preferredColorScheme(.dark)
34 .toolbar {
35 ToolbarItemGroup(placement: .topBarTrailing) {
36 if viewModel.hasRun {
37 Button {
38 viewModel.reset()
39 } label: {
40 Image(systemName: "xmark.circle")
41 .foregroundStyle(.secondary)
42 }
43 }
44 NavigationLink {
45 SavedDomainsView(viewModel: viewModel)
46 } label: {
47 Image(systemName: "bookmark")
48 .foregroundStyle(.secondary)
49 }
50 NavigationLink {
51 HistoryView(viewModel: viewModel)
52 } label: {
53 Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90")
54 .foregroundStyle(.secondary)
55 }
56 }
57 }
3958 }
4059 .onAppear {
4160 domainFieldFocused = true
@@ -72,6 +91,31 @@ struct ContentView: View {
7291 .padding(.vertical, 16)
7392 }
7493
94 // MARK: - Action Buttons (Share + Bookmark)
95
96 private var actionButtons: some View {
97 HStack {
98 Spacer()
99 if viewModel.resultsLoaded {
100 Button {
101 viewModel.toggleSavedDomain()
102 } label: {
103 Image(systemName: viewModel.isCurrentDomainSaved ? "bookmark.fill" : "bookmark")
104 .font(.system(.body))
105 .foregroundStyle(viewModel.isCurrentDomainSaved ? .yellow : .secondary)
106 }
107 Button {
108 shareResults()
109 } label: {
110 Image(systemName: "square.and.arrow.up")
111 .font(.system(.body))
112 .foregroundStyle(.secondary)
113 }
114 }
115 }
116 .padding(.top, 8)
117 }
118
75119 // MARK: - Recent Searches
76120
77121 private var recentSearchesSection: some View {
@@ -108,6 +152,110 @@ struct ContentView: View {
108152 .padding(.top, 8)
109153 }
110154
155 // MARK: - Reachability
156
157 private var reachabilitySection: some View {
158 VStack(alignment: .leading, spacing: 12) {
159 sectionHeader("Reachability")
160
161 if viewModel.reachabilityLoading {
162 ProgressView("Checking ports…")
163 .frame(maxWidth: .infinity, alignment: .center)
164 .padding()
165 } else if let error = viewModel.reachabilityError {
166 errorLabel(error)
167 } else {
168 VStack(alignment: .leading, spacing: 4) {
169 ForEach(viewModel.reachabilityResults) { result in
170 HStack(spacing: 8) {
171 Circle()
172 .fill(result.reachable ? Color.green : Color.red)
173 .frame(width: 8, height: 8)
174 Text("Port \(result.port)")
175 .font(.system(.caption, design: .monospaced))
176 if result.reachable, let ms = result.latencyMs {
177 Text("\(ms)ms")
178 .font(.system(.caption, design: .monospaced))
179 .foregroundStyle(.secondary)
180 } else if !result.reachable {
181 Text("—")
182 .font(.system(.caption, design: .monospaced))
183 .foregroundStyle(.secondary)
184 }
185 Spacer()
186 Text(result.reachable ? "Reachable" : "Unreachable")
187 .font(.system(.caption, design: .monospaced))
188 .foregroundStyle(result.reachable ? .green : .red)
189 }
190 }
191 }
192 .padding(10)
193 .background(Color(.systemGray6).opacity(0.5))
194 .cornerRadius(6)
195 }
196 }
197 .padding(.top, 8)
198 }
199
200 // MARK: - Redirect Chain
201
202 private var redirectChainSection: some View {
203 VStack(alignment: .leading, spacing: 12) {
204 sectionHeader("Redirect Chain")
205
206 if viewModel.redirectChainLoading {
207 ProgressView("Tracing redirects…")
208 .frame(maxWidth: .infinity, alignment: .center)
209 .padding()
210 } else if let error = viewModel.redirectChainError {
211 errorLabel(error)
212 } else if viewModel.redirectChain.isEmpty {
213 Text("No redirect data")
214 .font(.system(.caption, design: .monospaced))
215 .foregroundStyle(.secondary)
216 .padding(8)
217 } else if viewModel.redirectChain.count == 1,
218 let only = viewModel.redirectChain.first,
219 only.isFinal, !(300...399).contains(only.statusCode) {
220 Text("No redirects — direct connection")
221 .font(.system(.caption, design: .monospaced))
222 .foregroundStyle(.secondary)
223 .padding(10)
224 .frame(maxWidth: .infinity, alignment: .leading)
225 .background(Color(.systemGray6).opacity(0.5))
226 .cornerRadius(6)
227 } else {
228 VStack(alignment: .leading, spacing: 4) {
229 ForEach(viewModel.redirectChain) { hop in
230 HStack(alignment: .top, spacing: 6) {
231 Text("\(hop.stepNumber)")
232 .font(.system(.caption, design: .monospaced))
233 .foregroundStyle(.secondary)
234 .frame(width: 16, alignment: .trailing)
235 Text("\(hop.statusCode)")
236 .font(.system(.caption, design: .monospaced))
237 .foregroundStyle(.cyan)
238 .frame(width: 30, alignment: .leading)
239 Text(hop.url)
240 .font(.system(.caption, design: .monospaced))
241 .foregroundStyle(.primary)
242 .textSelection(.enabled)
243 if hop.isFinal {
244 Text("(final)")
245 .font(.system(.caption2, design: .monospaced))
246 .foregroundStyle(.secondary)
247 }
248 }
249 }
250 }
251 .padding(10)
252 .background(Color(.systemGray6).opacity(0.5))
253 .cornerRadius(6)
254 }
255 }
256 .padding(.top, 16)
257 }
258
111259 // MARK: - DNS Results
112260
113261 private var dnsResultsSection: some View {
@@ -123,10 +271,40 @@ struct ContentView: View {
123271 } else {
124272 ForEach(viewModel.dnsSections) { section in
125273 dnsRecordSection(section)
274 if section.recordType == .A {
275 ptrRow
276 }
126277 }
127278 }
128279 }
129 .padding(.top, 8)
280 .padding(.top, 16)
281 }
282
283 private var ptrRow: some View {
284 VStack(alignment: .leading, spacing: 4) {
285 Text("PTR (Reverse DNS)")
286 .font(.system(.subheadline, design: .monospaced))
287 .fontWeight(.semibold)
288 .foregroundStyle(.cyan)
289
290 if viewModel.ptrLoading {
291 ProgressView()
292 .frame(maxWidth: .infinity, alignment: .center)
293 .padding(4)
294 } else if let ptr = viewModel.ptrRecord {
295 Text(ptr)
296 .font(.system(.caption, design: .monospaced))
297 .foregroundStyle(.primary)
298 .textSelection(.enabled)
299 } else {
300 Text("No PTR record found")
301 .font(.system(.caption, design: .monospaced))
302 .foregroundStyle(.secondary)
303 }
304 }
305 .padding(10)
306 .background(Color(.systemGray6).opacity(0.5))
307 .cornerRadius(6)
130308 }
131309
132310 private func dnsRecordSection(_ section: DNSSection) -> some View {
@@ -146,7 +324,6 @@ struct ContentView: View {
146324 dnsRecordRows(section.records)
147325 }
148326
149 // Wildcard sub-section (only shown when records exist)
150327 if !section.wildcardRecords.isEmpty {
151328 Text("*.\(viewModel.searchedDomain)")
152329 .font(.system(.caption, design: .monospaced))
@@ -177,6 +354,66 @@ struct ContentView: View {
177354 }
178355 }
179356
357 // MARK: - Email Security
358
359 @State private var expandedEmailField: String?
360
361 private var emailSecuritySection: some View {
362 VStack(alignment: .leading, spacing: 12) {
363 sectionHeader("Email Security")
364
365 if viewModel.emailSecurityLoading {
366 ProgressView("Checking email records…")
367 .frame(maxWidth: .infinity, alignment: .center)
368 .padding()
369 } else if let error = viewModel.emailSecurityError {
370 errorLabel(error)
371 } else if let email = viewModel.emailSecurity {
372 VStack(alignment: .leading, spacing: 6) {
373 emailSecurityRow("SPF", record: email.spf)
374 emailSecurityRow("DMARC", record: email.dmarc)
375 emailSecurityRow("DKIM", record: email.dkim)
376 }
377 .padding(10)
378 .background(Color(.systemGray6).opacity(0.5))
379 .cornerRadius(6)
380 }
381 }
382 .padding(.top, 16)
383 }
384
385 private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View {
386 VStack(alignment: .leading, spacing: 2) {
387 HStack(spacing: 8) {
388 Text(label)
389 .font(.system(.caption, design: .monospaced))
390 .fontWeight(.semibold)
391 .frame(width: 52, alignment: .leading)
392 Text(record.found ? "✓" : "✗")
393 .font(.system(.caption, design: .monospaced))
394 .foregroundStyle(record.found ? .green : .red)
395 if let value = record.value {
396 let isExpanded = expandedEmailField == label
397 let displayValue = isExpanded ? value : String(value.prefix(80))
398 Text(displayValue)
399 .font(.system(.caption2, design: .monospaced))
400 .foregroundStyle(.primary)
401 .textSelection(.enabled)
402 .lineLimit(isExpanded ? nil : 1)
403 .onTapGesture {
404 withAnimation {
405 expandedEmailField = isExpanded ? nil : label
406 }
407 }
408 } else {
409 Text("No record found")
410 .font(.system(.caption2, design: .monospaced))
411 .foregroundStyle(.secondary)
412 }
413 }
414 }
415 }
416
180417 // MARK: - SSL Results
181418
182419 private var sslResultsSection: some View {
@@ -201,7 +438,6 @@ struct ContentView: View {
201438 certRow("Common Name", info.commonName)
202439 certRow("Issuer", info.issuer)
203440
204 // SANs
205441 VStack(alignment: .leading, spacing: 2) {
206442 Text("SANs")
207443 .font(.system(.caption2, design: .monospaced))
@@ -235,6 +471,136 @@ struct ContentView: View {
235471 .cornerRadius(6)
236472 }
237473
474 // MARK: - HTTP Headers
475
476 private var httpHeadersSection: some View {
477 VStack(alignment: .leading, spacing: 12) {
478 sectionHeader("HTTP Headers")
479
480 if viewModel.httpHeadersLoading {
481 ProgressView("Fetching headers…")
482 .frame(maxWidth: .infinity, alignment: .center)
483 .padding()
484 } else if let error = viewModel.httpHeadersError {
485 errorLabel(error)
486 } else {
487 VStack(alignment: .leading, spacing: 4) {
488 ForEach(viewModel.httpHeaders) { header in
489 HStack(alignment: .top, spacing: 4) {
490 Text(header.name + ":")
491 .font(.system(.caption, design: .monospaced))
492 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
493 Text(header.value)
494 .font(.system(.caption, design: .monospaced))
495 .foregroundStyle(.primary)
496 .textSelection(.enabled)
497 }
498 }
499 }
500 .padding(10)
501 .background(Color(.systemGray6).opacity(0.5))
502 .cornerRadius(6)
503 }
504 }
505 .padding(.top, 16)
506 }
507
508 // MARK: - IP Geolocation
509
510 private var ipGeolocationSection: some View {
511 VStack(alignment: .leading, spacing: 12) {
512 sectionHeader("IP Location")
513
514 if viewModel.ipGeolocationLoading {
515 ProgressView("Looking up location…")
516 .frame(maxWidth: .infinity, alignment: .center)
517 .padding()
518 } else if let geo = viewModel.ipGeolocation {
519 ipGeolocationDetail(geo)
520 } else if let error = viewModel.ipGeolocationError {
521 if error == "No A record available" {
522 Text("No location data available")
523 .font(.system(.caption, design: .monospaced))
524 .foregroundStyle(.secondary)
525 .padding(8)
526 } else {
527 errorLabel(error)
528 }
529 }
530 }
531 .padding(.top, 16)
532 }
533
534 private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View {
535 VStack(alignment: .leading, spacing: 6) {
536 certRow("IP", geo.ip)
537 if let org = geo.org {
538 certRow("Org / ISP", org)
539 }
540 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
541 if !location.isEmpty {
542 certRow("Location", location)
543 }
544 if let lat = geo.latitude, let lon = geo.longitude {
545 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
546 Map(initialPosition: .region(MKCoordinateRegion(
547 center: coordinate,
548 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
549 ))) {
550 Marker(geo.ip, coordinate: coordinate)
551 }
552 .mapStyle(.standard)
553 .frame(height: 180)
554 .cornerRadius(8)
555 }
556 }
557 .padding(10)
558 .background(Color(.systemGray6).opacity(0.5))
559 .cornerRadius(6)
560 }
561
562 // MARK: - Port Scan
563
564 private var portScanSection: some View {
565 VStack(alignment: .leading, spacing: 12) {
566 sectionHeader("Open Ports")
567
568 if viewModel.portScanLoading {
569 ProgressView("Scanning ports…")
570 .frame(maxWidth: .infinity, alignment: .center)
571 .padding()
572 } else if let error = viewModel.portScanError {
573 errorLabel(error)
574 } else {
575 VStack(alignment: .leading, spacing: 4) {
576 ForEach(viewModel.portScanResults) { result in
577 HStack(spacing: 8) {
578 Circle()
579 .fill(result.open ? Color.green : Color(.systemGray4))
580 .frame(width: 8, height: 8)
581 Text("\(result.port)")
582 .font(.system(.caption, design: .monospaced))
583 .frame(width: 44, alignment: .leading)
584 Text(result.service)
585 .font(.system(.caption, design: .monospaced))
586 .foregroundStyle(result.open ? .primary : .secondary)
587 Spacer()
588 if result.open {
589 Text("Open")
590 .font(.system(.caption2, design: .monospaced))
591 .foregroundStyle(.green)
592 }
593 }
594 }
595 }
596 .padding(10)
597 .background(Color(.systemGray6).opacity(0.5))
598 .cornerRadius(6)
599 }
600 }
601 .padding(.top, 16)
602 }
603
238604 // MARK: - Helpers
239605
240606 private func sectionHeader(_ title: String) -> some View {
@@ -269,8 +635,6 @@ struct ContentView: View {
269635
270636 private func shareResults() {
271637 let text = viewModel.exportText()
272
273 // Write to a named temp file so "Save to Files" uses a proper filename
274638 let dateFmt = DateFormatter()
275639 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
276640 let timestamp = dateFmt.string(from: Date())
@@ -295,7 +659,7 @@ struct ContentView: View {
295659 }
296660}
297661
298private extension DateFormatter {
662extension DateFormatter {
299663 static let certDate: DateFormatter = {
300664 let f = DateFormatter()
301665 f.dateStyle = .medium
DomainDig/DNSLookupService.swift +5 −4
@@ -35,15 +35,16 @@ struct DNSLookupService {
3535 }
3636 }
3737
38 /// Record types that support wildcard queries.
39 private nonisolated(unsafe) static let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
40
4138 static func lookupAll(domain: String) async -> [DNSSection] {
4239 // Each task returns (recordType, apex records, wildcard records).
4340 typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?)
4441
42 // Record types that support wildcard queries
43 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
44
4545 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
4646 for recordType in DNSRecordType.allCases {
47 let shouldQueryWildcard = wildcardTypes.contains(recordType)
4748 group.addTask {
4849 var apexRecords: [DNSRecord] = []
4950 var wildcardRecords: [DNSRecord] = []
@@ -57,7 +58,7 @@ struct DNSLookupService {
5758 }
5859
5960 // Wildcard query (only for applicable types, and only if apex didn't fail)
60 if wildcardTypes.contains(recordType) && lookupError == nil {
61 if shouldQueryWildcard && lookupError == nil {
6162 do {
6263 wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType)
6364 } catch {
DomainDig/DomainViewModel.swift +418 −10
@@ -6,22 +6,135 @@ import SwiftUI
66final class DomainViewModel {
77 var domain: String = ""
88
9 // DNS
910 var dnsSections: [DNSSection] = []
1011 var dnsLoading = false
1112 var dnsError: String?
1213
14 // SSL
1315 var sslInfo: SSLCertificateInfo?
1416 var sslLoading = false
1517 var sslError: String?
1618
19 // HTTP Headers
20 var httpHeaders: [HTTPHeader] = []
21 var httpHeadersLoading = false
22 var httpHeadersError: String?
23
24 // Reachability
25 var reachabilityResults: [PortReachability] = []
26 var reachabilityLoading = false
27 var reachabilityError: String?
28
29 // IP Geolocation
30 var ipGeolocation: IPGeolocation?
31 var ipGeolocationLoading = false
32 var ipGeolocationError: String?
33
34 // Email Security
35 var emailSecurity: EmailSecurityResult?
36 var emailSecurityLoading = false
37 var emailSecurityError: String?
38
39 // PTR / Reverse DNS
40 var ptrRecord: String?
41 var ptrLoading = false
42 var ptrError: String?
43
44 // Redirect Chain
45 var redirectChain: [RedirectHop] = []
46 var redirectChainLoading = false
47 var redirectChainError: String?
48
49 // Port Scan
50 var portScanResults: [PortScanResult] = []
51 var portScanLoading = false
52 var portScanError: String?
53
1754 var hasRun = false
1855 private(set) var searchedDomain: String = ""
1956
57 // MARK: - Recent Searches
58
2059 private static let recentSearchesKey = "recentSearches"
2160 private static let maxRecent = 20
2261
2362 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
2463
64 // MARK: - Saved Domains
65
66 private static let savedDomainsKey = "savedDomains"
67
68 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
69
70 var isCurrentDomainSaved: Bool {
71 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
72 }
73
74 func toggleSavedDomain() {
75 if isCurrentDomainSaved {
76 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
77 } else {
78 savedDomains.append(searchedDomain)
79 }
80 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
81 }
82
83 func removeSavedDomain(_ domain: String) {
84 savedDomains.removeAll { $0 == domain }
85 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
86 }
87
88 func removeSavedDomains(at offsets: IndexSet) {
89 savedDomains.remove(atOffsets: offsets)
90 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
91 }
92
93 // MARK: - History
94
95 private static let historyKey = "lookupHistory"
96 private static let maxHistory = 50
97
98 var history: [HistoryEntry] = {
99 guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
100 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
101 return []
102 }
103 return entries
104 }()
105
106 private func saveHistoryEntry() {
107 let entry = HistoryEntry(
108 domain: searchedDomain,
109 timestamp: Date(),
110 dnsSections: dnsSections,
111 sslInfo: sslInfo,
112 httpHeaders: httpHeaders,
113 reachabilityResults: reachabilityResults,
114 ipGeolocation: ipGeolocation,
115 emailSecurity: emailSecurity,
116 ptrRecord: ptrRecord,
117 redirectChain: redirectChain,
118 portScanResults: portScanResults
119 )
120 history.insert(entry, at: 0)
121 if history.count > Self.maxHistory {
122 history = Array(history.prefix(Self.maxHistory))
123 }
124 if let data = try? JSONEncoder().encode(history) {
125 UserDefaults.standard.set(data, forKey: Self.historyKey)
126 }
127 }
128
129 func removeHistoryEntries(at offsets: IndexSet) {
130 history.remove(atOffsets: offsets)
131 if let data = try? JSONEncoder().encode(history) {
132 UserDefaults.standard.set(data, forKey: Self.historyKey)
133 }
134 }
135
136 // MARK: - Computed
137
25138 var trimmedDomain: String {
26139 domain
27140 .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -30,6 +143,49 @@ final class DomainViewModel {
30143 .components(separatedBy: "/").first ?? ""
31144 }
32145
146 /// True when all lookups have finished (regardless of success/failure).
147 var resultsLoaded: Bool {
148 hasRun && !dnsLoading && !sslLoading && !httpHeadersLoading && !reachabilityLoading
149 && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
150 && !redirectChainLoading && !portScanLoading
151 }
152
153 // MARK: - Reset
154
155 func reset() {
156 hasRun = false
157 searchedDomain = ""
158 dnsSections = []
159 dnsError = nil
160 dnsLoading = false
161 sslInfo = nil
162 sslError = nil
163 sslLoading = false
164 httpHeaders = []
165 httpHeadersError = nil
166 httpHeadersLoading = false
167 reachabilityResults = []
168 reachabilityError = nil
169 reachabilityLoading = false
170 ipGeolocation = nil
171 ipGeolocationError = nil
172 ipGeolocationLoading = false
173 emailSecurity = nil
174 emailSecurityError = nil
175 emailSecurityLoading = false
176 ptrRecord = nil
177 ptrError = nil
178 ptrLoading = false
179 redirectChain = []
180 redirectChainError = nil
181 redirectChainLoading = false
182 portScanResults = []
183 portScanError = nil
184 portScanLoading = false
185 }
186
187 // MARK: - Run
188
33189 func run() {
34190 let target = trimmedDomain
35191 guard !target.isEmpty else { return }
@@ -37,25 +193,77 @@ final class DomainViewModel {
37193 addRecentSearch(target)
38194 searchedDomain = target
39195 hasRun = true
196
197 // Reset all state
40198 dnsSections = []
41199 dnsError = nil
42200 dnsLoading = true
43201 sslInfo = nil
44202 sslError = nil
45203 sslLoading = true
204 httpHeaders = []
205 httpHeadersError = nil
206 httpHeadersLoading = true
207 reachabilityResults = []
208 reachabilityError = nil
209 reachabilityLoading = true
210 ipGeolocation = nil
211 ipGeolocationError = nil
212 ipGeolocationLoading = true
213 emailSecurity = nil
214 emailSecurityError = nil
215 emailSecurityLoading = true
216 ptrRecord = nil
217 ptrError = nil
218 ptrLoading = true
219 redirectChain = []
220 redirectChainError = nil
221 redirectChainLoading = true
222 portScanResults = []
223 portScanError = nil
224 portScanLoading = true
46225
47226 Task {
48227 await withTaskGroup(of: Void.self) { group in
228 // DNS → chained: email security, PTR, geolocation
49229 group.addTask { @MainActor in
50230 await self.runDNS(domain: target)
231 // These depend on DNS results and run in parallel after DNS
232 await withTaskGroup(of: Void.self) { postDNS in
233 postDNS.addTask { @MainActor in
234 await self.runEmailSecurity(domain: target)
235 }
236 postDNS.addTask { @MainActor in
237 await self.runReverseDNS()
238 }
239 postDNS.addTask { @MainActor in
240 await self.runIPGeolocation()
241 }
242 }
51243 }
52244 group.addTask { @MainActor in
53245 await self.runSSL(domain: target)
54246 }
247 group.addTask { @MainActor in
248 await self.runHTTPHeaders(domain: target)
249 }
250 group.addTask { @MainActor in
251 await self.runReachability(domain: target)
252 }
253 group.addTask { @MainActor in
254 await self.runRedirectChain(domain: target)
255 }
256 group.addTask { @MainActor in
257 await self.runPortScan(domain: target)
258 }
55259 }
260 // Save history after all lookups complete so the snapshot is complete
261 saveHistoryEntry()
56262 }
57263 }
58264
265 // MARK: - Lookup Methods
266
59267 private func runDNS(domain: String) async {
60268 do {
61269 let sections = await DNSLookupService.lookupAll(domain: domain)
@@ -74,27 +282,157 @@ final class DomainViewModel {
74282 sslLoading = false
75283 }
76284
77 /// True when both lookups have finished (regardless of success/failure).
78 var resultsLoaded: Bool {
79 hasRun && !dnsLoading && !sslLoading
285 private func runHTTPHeaders(domain: String) async {
286 do {
287 let headers = try await HTTPHeadersService.fetch(domain: domain)
288 httpHeaders = headers
289 } catch {
290 httpHeadersError = error.localizedDescription
291 }
292 httpHeadersLoading = false
293 }
294
295 private func runReachability(domain: String) async {
296 let results = await ReachabilityService.checkAll(domain: domain)
297 reachabilityResults = results
298 reachabilityLoading = false
299 }
300
301 private func runIPGeolocation() async {
302 // Find the first A record IP
303 guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
304 let firstIP = aSection.records.first?.value else {
305 ipGeolocationError = "No A record available"
306 ipGeolocationLoading = false
307 return
308 }
309 do {
310 let geo = try await IPGeolocationService.lookup(ip: firstIP)
311 ipGeolocation = geo
312 } catch {
313 ipGeolocationError = error.localizedDescription
314 }
315 ipGeolocationLoading = false
316 }
317
318 private func runEmailSecurity(domain: String) async {
319 // Extract TXT records from already-fetched DNS sections
320 let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? []
321 let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords)
322 emailSecurity = result
323 emailSecurityLoading = false
324 }
325
326 private func runReverseDNS() async {
327 guard let aSection = dnsSections.first(where: { $0.recordType == .A }),
328 let firstIP = aSection.records.first?.value else {
329 ptrError = "No A record available"
330 ptrLoading = false
331 return
332 }
333 let result = await ReverseDNSService.lookup(ip: firstIP)
334 ptrRecord = result
335 if result == nil {
336 ptrError = "No PTR record found"
337 }
338 ptrLoading = false
339 }
340
341 private func runRedirectChain(domain: String) async {
342 do {
343 let hops = try await RedirectChainService.trace(domain: domain)
344 redirectChain = hops
345 } catch {
346 redirectChainError = error.localizedDescription
347 }
348 redirectChainLoading = false
349 }
350
351 private func runPortScan(domain: String) async {
352 let results = await PortScanService.scanAll(domain: domain)
353 portScanResults = results
354 portScanLoading = false
80355 }
81356
82357 // MARK: - Export
83358
84359 func exportText() -> String {
360 return Self.formatExportText(
361 domain: searchedDomain,
362 date: Date(),
363 dnsSections: dnsSections,
364 sslInfo: sslInfo,
365 sslError: sslError,
366 httpHeaders: httpHeaders,
367 httpHeadersError: httpHeadersError,
368 reachabilityResults: reachabilityResults,
369 ipGeolocation: ipGeolocation,
370 ipGeolocationError: ipGeolocationError,
371 emailSecurity: emailSecurity,
372 ptrRecord: ptrRecord,
373 redirectChain: redirectChain,
374 portScanResults: portScanResults
375 )
376 }
377
378 static func formatExportText(
379 domain: String,
380 date: Date,
381 dnsSections: [DNSSection],
382 sslInfo: SSLCertificateInfo?,
383 sslError: String? = nil,
384 httpHeaders: [HTTPHeader],
385 httpHeadersError: String? = nil,
386 reachabilityResults: [PortReachability],
387 ipGeolocation: IPGeolocation?,
388 ipGeolocationError: String? = nil,
389 emailSecurity: EmailSecurityResult? = nil,
390 ptrRecord: String? = nil,
391 redirectChain: [RedirectHop] = [],
392 portScanResults: [PortScanResult] = []
393 ) -> String {
85394 let dateFmt = DateFormatter()
86395 dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
87 let now = dateFmt.string(from: Date())
88396
89397 var lines: [String] = [
90398 "DomainDig Export",
91 "Domain: \(searchedDomain)",
92 "Date: \(now)",
93 "",
94 "DNS Records",
95 "-----------"
399 "Domain: \(domain)",
400 "Date: \(dateFmt.string(from: date))",
96401 ]
97402
403 // Reachability
404 if !reachabilityResults.isEmpty {
405 lines.append("")
406 lines.append("Reachability")
407 lines.append("------------")
408 for result in reachabilityResults {
409 if result.reachable, let ms = result.latencyMs {
410 lines.append(" Port \(result.port) \(ms)ms Reachable")
411 } else {
412 lines.append(" Port \(result.port) — Unreachable")
413 }
414 }
415 }
416
417 // Redirect Chain
418 if !redirectChain.isEmpty {
419 lines.append("")
420 lines.append("Redirect Chain")
421 lines.append("--------------")
422 if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) {
423 lines.append(" No redirects — direct connection")
424 } else {
425 for hop in redirectChain {
426 let final = hop.isFinal ? " (final)" : ""
427 lines.append(" \(hop.stepNumber) \(hop.statusCode) \(hop.url)\(final)")
428 }
429 }
430 }
431
432 // DNS
433 lines.append("")
434 lines.append("DNS Records")
435 lines.append("-----------")
98436 for section in dnsSections {
99437 lines.append(section.recordType.rawValue)
100438 if let error = section.error {
@@ -107,13 +445,30 @@ final class DomainViewModel {
107445 }
108446 }
109447 if !section.wildcardRecords.isEmpty {
110 lines.append("*.\(searchedDomain)")
448 lines.append("*.\(domain)")
111449 for record in section.wildcardRecords {
112450 lines.append(" \(record.value) TTL \(record.ttl)")
113451 }
114452 }
115453 }
116454
455 // PTR
456 if let ptr = ptrRecord {
457 lines.append("PTR (Reverse DNS)")
458 lines.append(" \(ptr)")
459 }
460
461 // Email Security
462 if let email = emailSecurity {
463 lines.append("")
464 lines.append("Email Security")
465 lines.append("--------------")
466 lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")")
467 lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")")
468 lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(email.dkim.value ?? "No record found")")
469 }
470
471 // SSL
117472 if let info = sslInfo {
118473 let certDateFmt = DateFormatter()
119474 certDateFmt.dateStyle = .medium
@@ -136,6 +491,59 @@ final class DomainViewModel {
136491 lines.append("Error: \(error)")
137492 }
138493
494 // HTTP Headers
495 if !httpHeaders.isEmpty {
496 lines.append("")
497 lines.append("HTTP Headers")
498 lines.append("------------")
499 for header in httpHeaders {
500 lines.append(" \(header.name): \(header.value)")
501 }
502 } else if let error = httpHeadersError {
503 lines.append("")
504 lines.append("HTTP Headers")
505 lines.append("------------")
506 lines.append("Error: \(error)")
507 }
508
509 // IP Geolocation
510 if let geo = ipGeolocation {
511 lines.append("")
512 lines.append("IP Location")
513 lines.append("-----------")
514 lines.append("IP: \(geo.ip)")
515 if let org = geo.org { lines.append("Org: \(org)") }
516 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
517 if !location.isEmpty { lines.append("Location: \(location)") }
518 if let lat = geo.latitude, let lon = geo.longitude {
519 lines.append("Coordinates: \(lat), \(lon)")
520 }
521 } else if let error = ipGeolocationError, error != "No A record available" {
522 lines.append("")
523 lines.append("IP Location")
524 lines.append("-----------")
525 lines.append("Error: \(error)")
526 }
527
528 // Open Ports
529 if !portScanResults.isEmpty {
530 lines.append("")
531 lines.append("Open Ports")
532 lines.append("----------")
533 let openPorts = portScanResults.filter { $0.open }
534 if openPorts.isEmpty {
535 lines.append(" No open ports detected")
536 } else {
537 for port in openPorts {
538 lines.append(" \(port.port) \(port.service)")
539 }
540 }
541 let closedPorts = portScanResults.filter { !$0.open }
542 if !closedPorts.isEmpty {
543 lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))")
544 }
545 }
546
139547 return lines.joined(separator: "\n")
140548 }
141549
DomainDig/EmailSecurityService.swift added +61
@@ -0,0 +1,61 @@
1import Foundation
2
3struct EmailSecurityService {
4 /// Analyze email security records. SPF is parsed from existing TXT records;
5 /// DMARC and DKIM require additional DoH queries.
6 static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult {
7 // SPF: extract from existing TXT records
8 let spfRecord = txtRecords.first(where: { $0.value.lowercased().hasPrefix("v=spf1") })
9 let spf = EmailSecurityRecord(found: spfRecord != nil, value: spfRecord?.value)
10
11 // DMARC and DKIM queries in parallel
12 async let dmarcResult = queryTXT(subdomain: "_dmarc.\(domain)")
13 async let dkimResult = queryDKIM(domain: domain)
14
15 let dmarcValue = await dmarcResult
16 let dkimValue = await dkimResult
17
18 let dmarc = EmailSecurityRecord(
19 found: dmarcValue != nil,
20 value: dmarcValue
21 )
22 let dkim = EmailSecurityRecord(
23 found: dkimValue != nil,
24 value: dkimValue
25 )
26
27 return EmailSecurityResult(spf: spf, dmarc: dmarc, dkim: dkim)
28 }
29
30 /// Query a TXT record for the given subdomain via DoH.
31 private static func queryTXT(subdomain: String) async -> String? {
32 do {
33 let records = try await DNSLookupService.lookup(domain: subdomain, recordType: .TXT)
34 return records.first?.value
35 } catch {
36 return nil
37 }
38 }
39
40 /// Try common DKIM selectors and return the first found.
41 private static func queryDKIM(domain: String) async -> String? {
42 let selectors = ["default", "google", "mail"]
43 return await withTaskGroup(of: (Int, String?).self, returning: String?.self) { group in
44 for (index, selector) in selectors.enumerated() {
45 group.addTask {
46 let value = await queryTXT(subdomain: "\(selector)._domainkey.\(domain)")
47 return (index, value)
48 }
49 }
50
51 var results: [(Int, String?)] = []
52 for await result in group {
53 results.append(result)
54 }
55 // Return the first (by selector order) that has a value
56 return results
57 .sorted { $0.0 < $1.0 }
58 .first(where: { $0.1 != nil })?.1
59 }
60 }
61}
DomainDig/HTTPHeadersService.swift added +22
@@ -0,0 +1,22 @@
1import Foundation
2
3struct HTTPHeadersService {
4 static func fetch(domain: String) async throws -> [HTTPHeader] {
5 let url = URL(string: "https://\(domain)")!
6 var request = URLRequest(url: url, timeoutInterval: 10)
7 request.httpMethod = "HEAD"
8
9 let (_, response) = try await URLSession.shared.data(for: request)
10
11 guard let httpResponse = response as? HTTPURLResponse else {
12 throw URLError(.badServerResponse)
13 }
14
15 return httpResponse.allHeaderFields.compactMap { key, value in
16 guard let name = key as? String,
17 let val = value as? String else { return nil }
18 return HTTPHeader(name: name, value: val)
19 }
20 .sorted { $0.name.lowercased() < $1.name.lowercased() }
21 }
22}
DomainDig/HistoryView.swift added +496
@@ -0,0 +1,496 @@
1import SwiftUI
2import MapKit
3
4struct HistoryView: View {
5 @Bindable var viewModel: DomainViewModel
6 @Environment(\.dismiss) private var dismiss
7
8 private let dateFmt: DateFormatter = {
9 let f = DateFormatter()
10 f.dateStyle = .medium
11 f.timeStyle = .short
12 return f
13 }()
14
15 var body: some View {
16 List {
17 if viewModel.history.isEmpty {
18 Text("No lookup history")
19 .font(.system(.callout, design: .monospaced))
20 .foregroundStyle(.secondary)
21 .listRowBackground(Color(.systemGray6).opacity(0.5))
22 } else {
23 ForEach(viewModel.history) { entry in
24 NavigationLink {
25 HistoryDetailView(entry: entry)
26 } label: {
27 VStack(alignment: .leading, spacing: 2) {
28 Text(entry.domain)
29 .font(.system(.callout, design: .monospaced))
30 .foregroundStyle(.primary)
31 Text(dateFmt.string(from: entry.timestamp))
32 .font(.system(.caption2, design: .monospaced))
33 .foregroundStyle(.secondary)
34 }
35 }
36 .listRowBackground(Color(.systemGray6).opacity(0.5))
37 }
38 .onDelete { offsets in
39 viewModel.removeHistoryEntries(at: offsets)
40 }
41 }
42 }
43 .scrollContentBackground(.hidden)
44 .background(Color.black)
45 .navigationTitle("History")
46 .toolbar {
47 if !viewModel.history.isEmpty {
48 EditButton()
49 }
50 }
51 .preferredColorScheme(.dark)
52 }
53}
54
55// MARK: - History Detail View (Read-Only Cached Results)
56
57struct HistoryDetailView: View {
58 let entry: HistoryEntry
59
60 private let dateFmt: DateFormatter = {
61 let f = DateFormatter()
62 f.dateStyle = .medium
63 f.timeStyle = .short
64 return f
65 }()
66
67 var body: some View {
68 ScrollView {
69 VStack(alignment: .leading, spacing: 0) {
70 cachedBanner
71 reachabilitySection
72 redirectChainSection
73 dnsSection
74 emailSecuritySection
75 sslSection
76 httpHeadersSection
77 ipGeolocationSection
78 portScanSection
79 }
80 .padding(.horizontal)
81 .padding(.bottom, 32)
82 }
83 .background(Color.black)
84 .navigationTitle(entry.domain)
85 .preferredColorScheme(.dark)
86 }
87
88 // MARK: - Cached Banner
89
90 private var cachedBanner: some View {
91 HStack(spacing: 6) {
92 Image(systemName: "archivebox")
93 .font(.caption)
94 Text("Cached result from \(dateFmt.string(from: entry.timestamp))")
95 .font(.system(.caption, design: .monospaced))
96 }
97 .foregroundStyle(.secondary)
98 .padding(8)
99 .frame(maxWidth: .infinity, alignment: .leading)
100 .background(Color(.systemGray6).opacity(0.3))
101 .cornerRadius(6)
102 .padding(.vertical, 12)
103 }
104
105 // MARK: - Reachability
106
107 private var reachabilitySection: some View {
108 VStack(alignment: .leading, spacing: 12) {
109 if !entry.reachabilityResults.isEmpty {
110 sectionHeader("Reachability")
111 VStack(alignment: .leading, spacing: 4) {
112 ForEach(entry.reachabilityResults) { result in
113 HStack(spacing: 8) {
114 Circle()
115 .fill(result.reachable ? Color.green : Color.red)
116 .frame(width: 8, height: 8)
117 Text("Port \(result.port)")
118 .font(.system(.caption, design: .monospaced))
119 if result.reachable, let ms = result.latencyMs {
120 Text("\(ms)ms")
121 .font(.system(.caption, design: .monospaced))
122 .foregroundStyle(.secondary)
123 } else if !result.reachable {
124 Text("—")
125 .font(.system(.caption, design: .monospaced))
126 .foregroundStyle(.secondary)
127 }
128 Spacer()
129 Text(result.reachable ? "Reachable" : "Unreachable")
130 .font(.system(.caption, design: .monospaced))
131 .foregroundStyle(result.reachable ? .green : .red)
132 }
133 }
134 }
135 .padding(10)
136 .background(Color(.systemGray6).opacity(0.5))
137 .cornerRadius(6)
138 }
139 }
140 .padding(.top, 8)
141 }
142
143 // MARK: - Redirect Chain
144
145 private var redirectChainSection: some View {
146 VStack(alignment: .leading, spacing: 12) {
147 if !entry.redirectChain.isEmpty {
148 sectionHeader("Redirect Chain")
149 if entry.redirectChain.count == 1,
150 let only = entry.redirectChain.first,
151 only.isFinal, !(300...399).contains(only.statusCode) {
152 Text("No redirects — direct connection")
153 .font(.system(.caption, design: .monospaced))
154 .foregroundStyle(.secondary)
155 .padding(10)
156 .frame(maxWidth: .infinity, alignment: .leading)
157 .background(Color(.systemGray6).opacity(0.5))
158 .cornerRadius(6)
159 } else {
160 VStack(alignment: .leading, spacing: 4) {
161 ForEach(entry.redirectChain) { hop in
162 HStack(alignment: .top, spacing: 6) {
163 Text("\(hop.stepNumber)")
164 .font(.system(.caption, design: .monospaced))
165 .foregroundStyle(.secondary)
166 .frame(width: 16, alignment: .trailing)
167 Text("\(hop.statusCode)")
168 .font(.system(.caption, design: .monospaced))
169 .foregroundStyle(.cyan)
170 .frame(width: 30, alignment: .leading)
171 Text(hop.url)
172 .font(.system(.caption, design: .monospaced))
173 .foregroundStyle(.primary)
174 .textSelection(.enabled)
175 if hop.isFinal {
176 Text("(final)")
177 .font(.system(.caption2, design: .monospaced))
178 .foregroundStyle(.secondary)
179 }
180 }
181 }
182 }
183 .padding(10)
184 .background(Color(.systemGray6).opacity(0.5))
185 .cornerRadius(6)
186 }
187 }
188 }
189 .padding(.top, 16)
190 }
191
192 // MARK: - DNS
193
194 private var dnsSection: some View {
195 VStack(alignment: .leading, spacing: 12) {
196 sectionHeader("DNS Records")
197 ForEach(entry.dnsSections) { section in
198 VStack(alignment: .leading, spacing: 4) {
199 Text(section.recordType.rawValue)
200 .font(.system(.subheadline, design: .monospaced))
201 .fontWeight(.semibold)
202 .foregroundStyle(.cyan)
203
204 if let error = section.error {
205 errorLabel(error)
206 } else if section.records.isEmpty {
207 Text("No records found")
208 .font(.system(.caption, design: .monospaced))
209 .foregroundStyle(.secondary)
210 } else {
211 recordRows(section.records)
212 }
213
214 if !section.wildcardRecords.isEmpty {
215 Text("*.\(entry.domain)")
216 .font(.system(.caption, design: .monospaced))
217 .fontWeight(.medium)
218 .foregroundStyle(.cyan.opacity(0.7))
219 .padding(.top, 4)
220 recordRows(section.wildcardRecords)
221 }
222 }
223 .padding(10)
224 .background(Color(.systemGray6).opacity(0.5))
225 .cornerRadius(6)
226
227 if section.recordType == .A {
228 VStack(alignment: .leading, spacing: 4) {
229 Text("PTR (Reverse DNS)")
230 .font(.system(.subheadline, design: .monospaced))
231 .fontWeight(.semibold)
232 .foregroundStyle(.cyan)
233
234 if let ptr = entry.ptrRecord {
235 Text(ptr)
236 .font(.system(.caption, design: .monospaced))
237 .foregroundStyle(.primary)
238 .textSelection(.enabled)
239 } else {
240 Text("No PTR record found")
241 .font(.system(.caption, design: .monospaced))
242 .foregroundStyle(.secondary)
243 }
244 }
245 .padding(10)
246 .background(Color(.systemGray6).opacity(0.5))
247 .cornerRadius(6)
248 }
249 }
250 }
251 .padding(.top, 16)
252 }
253
254 // MARK: - Email Security
255
256 @State private var expandedEmailField: String?
257
258 private var emailSecuritySection: some View {
259 VStack(alignment: .leading, spacing: 12) {
260 if let email = entry.emailSecurity {
261 sectionHeader("Email Security")
262 VStack(alignment: .leading, spacing: 6) {
263 historyEmailRow("SPF", record: email.spf)
264 historyEmailRow("DMARC", record: email.dmarc)
265 historyEmailRow("DKIM", record: email.dkim)
266 }
267 .padding(10)
268 .background(Color(.systemGray6).opacity(0.5))
269 .cornerRadius(6)
270 }
271 }
272 .padding(.top, 16)
273 }
274
275 private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View {
276 VStack(alignment: .leading, spacing: 2) {
277 HStack(spacing: 8) {
278 Text(label)
279 .font(.system(.caption, design: .monospaced))
280 .fontWeight(.semibold)
281 .frame(width: 52, alignment: .leading)
282 Text(record.found ? "✓" : "✗")
283 .font(.system(.caption, design: .monospaced))
284 .foregroundStyle(record.found ? .green : .red)
285 if let value = record.value {
286 let isExpanded = expandedEmailField == label
287 let displayValue = isExpanded ? value : String(value.prefix(80))
288 Text(displayValue)
289 .font(.system(.caption2, design: .monospaced))
290 .foregroundStyle(.primary)
291 .textSelection(.enabled)
292 .lineLimit(isExpanded ? nil : 1)
293 .onTapGesture {
294 withAnimation {
295 expandedEmailField = isExpanded ? nil : label
296 }
297 }
298 } else {
299 Text("No record found")
300 .font(.system(.caption2, design: .monospaced))
301 .foregroundStyle(.secondary)
302 }
303 }
304 }
305 }
306
307 // MARK: - SSL
308
309 private var sslSection: some View {
310 VStack(alignment: .leading, spacing: 12) {
311 if let info = entry.sslInfo {
312 sectionHeader("SSL / TLS Certificate")
313 VStack(alignment: .leading, spacing: 8) {
314 labelRow("Common Name", info.commonName)
315 labelRow("Issuer", info.issuer)
316
317 VStack(alignment: .leading, spacing: 2) {
318 Text("SANs")
319 .font(.system(.caption2, design: .monospaced))
320 .foregroundStyle(.secondary)
321 ForEach(info.subjectAltNames, id: \.self) { san in
322 Text(san)
323 .font(.system(.caption, design: .monospaced))
324 .textSelection(.enabled)
325 }
326 }
327
328 labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom))
329 labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil))
330
331 HStack {
332 Text("Days Until Expiry")
333 .font(.system(.caption2, design: .monospaced))
334 .foregroundStyle(.secondary)
335 Spacer()
336 Text("\(info.daysUntilExpiry)")
337 .font(.system(.caption, design: .monospaced))
338 .fontWeight(.bold)
339 .foregroundStyle(expiryColor(info.daysUntilExpiry))
340 }
341
342 labelRow("Chain Depth", "\(info.chainDepth)")
343 }
344 .padding(10)
345 .background(Color(.systemGray6).opacity(0.5))
346 .cornerRadius(6)
347 }
348 }
349 .padding(.top, 16)
350 }
351
352 // MARK: - HTTP Headers
353
354 private var httpHeadersSection: some View {
355 VStack(alignment: .leading, spacing: 12) {
356 if !entry.httpHeaders.isEmpty {
357 sectionHeader("HTTP Headers")
358 VStack(alignment: .leading, spacing: 4) {
359 ForEach(entry.httpHeaders) { header in
360 HStack(alignment: .top, spacing: 4) {
361 Text(header.name + ":")
362 .font(.system(.caption, design: .monospaced))
363 .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan)
364 Text(header.value)
365 .font(.system(.caption, design: .monospaced))
366 .foregroundStyle(.primary)
367 .textSelection(.enabled)
368 }
369 }
370 }
371 .padding(10)
372 .background(Color(.systemGray6).opacity(0.5))
373 .cornerRadius(6)
374 }
375 }
376 .padding(.top, 16)
377 }
378
379 // MARK: - IP Geolocation
380
381 private var ipGeolocationSection: some View {
382 VStack(alignment: .leading, spacing: 12) {
383 if let geo = entry.ipGeolocation {
384 sectionHeader("IP Location")
385 VStack(alignment: .leading, spacing: 6) {
386 labelRow("IP", geo.ip)
387 if let org = geo.org {
388 labelRow("Org / ISP", org)
389 }
390 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
391 if !location.isEmpty {
392 labelRow("Location", location)
393 }
394 if let lat = geo.latitude, let lon = geo.longitude {
395 let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon)
396 Map(initialPosition: .region(MKCoordinateRegion(
397 center: coordinate,
398 span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1)
399 ))) {
400 Marker(geo.ip, coordinate: coordinate)
401 }
402 .mapStyle(.standard)
403 .frame(height: 180)
404 .cornerRadius(8)
405 }
406 }
407 .padding(10)
408 .background(Color(.systemGray6).opacity(0.5))
409 .cornerRadius(6)
410 }
411 }
412 .padding(.top, 16)
413 }
414
415 // MARK: - Port Scan
416
417 private var portScanSection: some View {
418 VStack(alignment: .leading, spacing: 12) {
419 if !entry.portScanResults.isEmpty {
420 sectionHeader("Open Ports")
421 VStack(alignment: .leading, spacing: 4) {
422 ForEach(entry.portScanResults) { result in
423 HStack(spacing: 8) {
424 Circle()
425 .fill(result.open ? Color.green : Color(.systemGray4))
426 .frame(width: 8, height: 8)
427 Text("\(result.port)")
428 .font(.system(.caption, design: .monospaced))
429 .frame(width: 44, alignment: .leading)
430 Text(result.service)
431 .font(.system(.caption, design: .monospaced))
432 .foregroundStyle(result.open ? .primary : .secondary)
433 Spacer()
434 if result.open {
435 Text("Open")
436 .font(.system(.caption2, design: .monospaced))
437 .foregroundStyle(.green)
438 }
439 }
440 }
441 }
442 .padding(10)
443 .background(Color(.systemGray6).opacity(0.5))
444 .cornerRadius(6)
445 }
446 }
447 .padding(.top, 16)
448 }
449
450 // MARK: - Helpers
451
452 private func sectionHeader(_ title: String) -> some View {
453 Text(title)
454 .font(.system(.headline, design: .default))
455 .foregroundStyle(.white)
456 }
457
458 private func labelRow(_ label: String, _ value: String) -> some View {
459 VStack(alignment: .leading, spacing: 2) {
460 Text(label)
461 .font(.system(.caption2, design: .monospaced))
462 .foregroundStyle(.secondary)
463 Text(value)
464 .font(.system(.caption, design: .monospaced))
465 .textSelection(.enabled)
466 }
467 }
468
469 private func recordRows(_ records: [DNSRecord]) -> some View {
470 ForEach(records) { record in
471 HStack(alignment: .top) {
472 Text(record.value)
473 .font(.system(.caption, design: .monospaced))
474 .foregroundStyle(.primary)
475 .textSelection(.enabled)
476 Spacer()
477 Text("TTL \(record.ttl)")
478 .font(.system(.caption2, design: .monospaced))
479 .foregroundStyle(.secondary)
480 }
481 }
482 }
483
484 private func errorLabel(_ message: String) -> some View {
485 Label(message, systemImage: "exclamationmark.triangle.fill")
486 .font(.system(.caption, design: .monospaced))
487 .foregroundStyle(.red)
488 .padding(8)
489 }
490
491 private func expiryColor(_ days: Int) -> Color {
492 if days < 30 { return .red }
493 if days < 60 { return .yellow }
494 return .green
495 }
496}
DomainDig/IPGeolocationService.swift added +17
@@ -0,0 +1,17 @@
1import Foundation
2
3struct IPGeolocationService {
4 static func lookup(ip: String) async throws -> IPGeolocation {
5 let url = URL(string: "https://ipapi.co/\(ip)/json/")!
6 let request = URLRequest(url: url, timeoutInterval: 10)
7
8 let (data, response) = try await URLSession.shared.data(for: request)
9
10 guard let httpResponse = response as? HTTPURLResponse,
11 httpResponse.statusCode == 200 else {
12 throw URLError(.badServerResponse)
13 }
14
15 return try JSONDecoder().decode(IPGeolocation.self, from: data)
16 }
17}
DomainDig/Info.plist added +10
@@ -0,0 +1,10 @@
1<?xml version="1.0" encoding="UTF-8"?>
2<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3<plist version="1.0">
4<dict>
5 <key>Allow ​Arbitrary ​Loads (or NSAllows​Arbitrary​Loads) </key>
6 <true/>
7 <key>App ​Transport ​Security ​Settings (or NSApp​Transport​Security)</key>
8 <dict/>
9</dict>
10</plist>
DomainDig/Models.swift +130 −6
@@ -2,7 +2,7 @@ import Foundation
22
33// MARK: - DNS Models
44
5enum DNSRecordType: String, CaseIterable {
5enum DNSRecordType: String, CaseIterable, Codable {
66 case A
77 case AAAA
88 case MX
@@ -22,14 +22,14 @@ enum DNSRecordType: String, CaseIterable {
2222 }
2323}
2424
25struct DNSRecord: Identifiable {
26 let id = UUID()
25struct DNSRecord: Identifiable, Codable {
26 var id = UUID()
2727 let value: String
2828 let ttl: Int
2929}
3030
31struct DNSSection: Identifiable {
32 let id = UUID()
31struct DNSSection: Identifiable, Codable {
32 var id = UUID()
3333 let recordType: DNSRecordType
3434 var records: [DNSRecord]
3535 var wildcardRecords: [DNSRecord] = []
@@ -38,7 +38,7 @@ struct DNSSection: Identifiable {
3838
3939// MARK: - SSL Models
4040
41struct SSLCertificateInfo {
41struct SSLCertificateInfo: Codable {
4242 let commonName: String
4343 let subjectAltNames: [String]
4444 let issuer: String
@@ -48,6 +48,130 @@ struct SSLCertificateInfo {
4848 let chainDepth: Int
4949}
5050
51// MARK: - HTTP Headers Models
52
53struct HTTPHeader: Identifiable, Codable {
54 var id = UUID()
55 let name: String
56 let value: String
57
58 static let securityHeaders: Set<String> = [
59 "strict-transport-security",
60 "x-frame-options",
61 "x-content-type-options",
62 "content-security-policy",
63 "referrer-policy"
64 ]
65
66 var isSecurityHeader: Bool {
67 Self.securityHeaders.contains(name.lowercased())
68 }
69}
70
71// MARK: - Reachability Models
72
73struct PortReachability: Identifiable, Codable {
74 var id = UUID()
75 let port: UInt16
76 let reachable: Bool
77 let latencyMs: Int?
78}
79
80// MARK: - IP Geolocation Models
81
82struct IPGeolocation: Codable {
83 let ip: String
84 let city: String?
85 let region: String?
86 let country_name: String?
87 let org: String?
88 let latitude: Double?
89 let longitude: Double?
90}
91
92// MARK: - Email Security Models
93
94struct EmailSecurityResult: Codable {
95 let spf: EmailSecurityRecord
96 let dmarc: EmailSecurityRecord
97 let dkim: EmailSecurityRecord
98}
99
100struct EmailSecurityRecord: Codable {
101 let found: Bool
102 let value: String?
103}
104
105// MARK: - Redirect Chain Models
106
107struct RedirectHop: Identifiable, Codable {
108 var id = UUID()
109 let stepNumber: Int
110 let statusCode: Int
111 let url: String
112 let isFinal: Bool
113}
114
115// MARK: - Port Scan Models
116
117struct PortScanResult: Identifiable, Codable {
118 var id = UUID()
119 let port: UInt16
120 let service: String
121 let open: Bool
122}
123
124// MARK: - History Models
125
126struct HistoryEntry: Identifiable, Codable {
127 var id = UUID()
128 let domain: String
129 let timestamp: Date
130 let dnsSections: [DNSSection]
131 let sslInfo: SSLCertificateInfo?
132 let httpHeaders: [HTTPHeader]
133 let reachabilityResults: [PortReachability]
134 let ipGeolocation: IPGeolocation?
135 var emailSecurity: EmailSecurityResult?
136 var ptrRecord: String?
137 var redirectChain: [RedirectHop]
138 var portScanResults: [PortScanResult]
139
140 init(domain: String, timestamp: Date, dnsSections: [DNSSection],
141 sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader],
142 reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?,
143 emailSecurity: EmailSecurityResult? = nil, ptrRecord: String? = nil,
144 redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = []) {
145 self.domain = domain
146 self.timestamp = timestamp
147 self.dnsSections = dnsSections
148 self.sslInfo = sslInfo
149 self.httpHeaders = httpHeaders
150 self.reachabilityResults = reachabilityResults
151 self.ipGeolocation = ipGeolocation
152 self.emailSecurity = emailSecurity
153 self.ptrRecord = ptrRecord
154 self.redirectChain = redirectChain
155 self.portScanResults = portScanResults
156 }
157
158 init(from decoder: Decoder) throws {
159 let container = try decoder.container(keyedBy: CodingKeys.self)
160 id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
161 domain = try container.decode(String.self, forKey: .domain)
162 timestamp = try container.decode(Date.self, forKey: .timestamp)
163 dnsSections = try container.decode([DNSSection].self, forKey: .dnsSections)
164 sslInfo = try container.decodeIfPresent(SSLCertificateInfo.self, forKey: .sslInfo)
165 httpHeaders = try container.decode([HTTPHeader].self, forKey: .httpHeaders)
166 reachabilityResults = try container.decode([PortReachability].self, forKey: .reachabilityResults)
167 ipGeolocation = try container.decodeIfPresent(IPGeolocation.self, forKey: .ipGeolocation)
168 emailSecurity = try container.decodeIfPresent(EmailSecurityResult.self, forKey: .emailSecurity)
169 ptrRecord = try container.decodeIfPresent(String.self, forKey: .ptrRecord)
170 redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? []
171 portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? []
172 }
173}
174
51175// MARK: - Cloudflare DNS-over-HTTPS Response
52176
53177struct CloudflareDNSResponse: Decodable {
DomainDig/PortScanService.swift added +93
@@ -0,0 +1,93 @@
1import Foundation
2import Network
3
4struct PortScanService {
5 struct PortInfo: Sendable {
6 let port: UInt16
7 let service: String
8 }
9
10 static let ports: [PortInfo] = [
11 PortInfo(port: 21, service: "FTP"),
12 PortInfo(port: 22, service: "SSH"),
13 PortInfo(port: 25, service: "SMTP"),
14 PortInfo(port: 80, service: "HTTP"),
15 PortInfo(port: 443, service: "HTTPS"),
16 PortInfo(port: 587, service: "SMTP (TLS)"),
17 PortInfo(port: 3306, service: "MySQL"),
18 PortInfo(port: 5432, service: "PostgreSQL"),
19 PortInfo(port: 8080, service: "HTTP Alt"),
20 PortInfo(port: 8443, service: "HTTPS Alt"),
21 ]
22
23 static func scanAll(domain: String) async -> [PortScanResult] {
24 await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in
25 for info in ports {
26 group.addTask {
27 let open = await probe(domain: domain, port: info.port)
28 return PortScanResult(port: info.port, service: info.service, open: open)
29 }
30 }
31
32 var results: [PortScanResult] = []
33 for await result in group {
34 results.append(result)
35 }
36
37 // Sort by port number
38 return results.sorted { $0.port < $1.port }
39 }
40 }
41
42 private static func probe(domain: String, port: UInt16) async -> Bool {
43 await withCheckedContinuation { continuation in
44 let host = NWEndpoint.Host(domain)
45 let nwPort = NWEndpoint.Port(rawValue: port)!
46 let connection = NWConnection(host: host, port: nwPort, using: .tcp)
47 let context = ProbeContext(connection: connection, continuation: continuation)
48
49 connection.stateUpdateHandler = { state in
50 switch state {
51 case .ready:
52 context.finish(open: true)
53 case .failed, .cancelled:
54 context.finish(open: false)
55 default:
56 break
57 }
58 }
59
60 let queue = DispatchQueue(label: "portscan.\(port)")
61 connection.start(queue: queue)
62
63 queue.asyncAfter(deadline: .now() + 3) {
64 context.finish(open: false)
65 }
66 }
67 }
68}
69
70private final class ProbeContext: @unchecked Sendable {
71 private let connection: NWConnection
72 private let continuation: CheckedContinuation<Bool, Never>
73 private let lock = NSLock()
74 private nonisolated(unsafe) var resumed = false
75
76 init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) {
77 self.connection = connection
78 self.continuation = continuation
79 }
80
81 nonisolated func finish(open: Bool) {
82 lock.lock()
83 guard !resumed else {
84 lock.unlock()
85 return
86 }
87 resumed = true
88 lock.unlock()
89
90 connection.cancel()
91 continuation.resume(returning: open)
92 }
93}
DomainDig/ReachabilityService.swift added +67
@@ -0,0 +1,67 @@
1import Foundation
2import Network
3
4struct ReachabilityService {
5 static func check(domain: String, port: UInt16) async -> PortReachability {
6 await withCheckedContinuation { continuation in
7 let host = NWEndpoint.Host(domain)
8 let nwPort = NWEndpoint.Port(rawValue: port)!
9 let connection = NWConnection(host: host, port: nwPort, using: .tcp)
10 let context = ConnectionContext(port: port, connection: connection, continuation: continuation)
11
12 connection.stateUpdateHandler = { state in
13 switch state {
14 case .ready:
15 context.finish(reachable: true)
16 case .failed, .cancelled:
17 context.finish(reachable: false)
18 default:
19 break
20 }
21 }
22
23 let queue = DispatchQueue(label: "reachability.\(port)")
24 connection.start(queue: queue)
25
26 queue.asyncAfter(deadline: .now() + 5) {
27 context.finish(reachable: false)
28 }
29 }
30 }
31
32 static func checkAll(domain: String) async -> [PortReachability] {
33 async let port443 = check(domain: domain, port: 443)
34 async let port80 = check(domain: domain, port: 80)
35 return await [port443, port80]
36 }
37}
38
39private final class ConnectionContext: @unchecked Sendable {
40 private let port: UInt16
41 private let connection: NWConnection
42 private let continuation: CheckedContinuation<PortReachability, Never>
43 private let start = CFAbsoluteTimeGetCurrent()
44 private let lock = NSLock()
45 private nonisolated(unsafe) var resumed = false
46
47 init(port: UInt16, connection: NWConnection, continuation: CheckedContinuation<PortReachability, Never>) {
48 self.port = port
49 self.connection = connection
50 self.continuation = continuation
51 }
52
53 nonisolated func finish(reachable: Bool) {
54 lock.lock()
55 guard !resumed else {
56 lock.unlock()
57 return
58 }
59 resumed = true
60 lock.unlock()
61
62 let elapsed = CFAbsoluteTimeGetCurrent() - start
63 let ms = reachable ? Int(elapsed * 1000) : nil
64 connection.cancel()
65 continuation.resume(returning: PortReachability(port: port, reachable: reachable, latencyMs: ms))
66 }
67}
DomainDig/RedirectChainService.swift added +82
@@ -0,0 +1,82 @@
1import Foundation
2
3struct RedirectChainService {
4 static func trace(domain: String) async throws -> [RedirectHop] {
5 // Try HTTPS first (avoids ATS issues), fall back to HTTP if it fails entirely
6 do {
7 return try await followChain(startingURL: URL(string: "https://\(domain)")!)
8 } catch {
9 return try await followChain(startingURL: URL(string: "http://\(domain)")!)
10 }
11 }
12
13 private static func followChain(startingURL: URL) async throws -> [RedirectHop] {
14 let delegate = NoRedirectDelegate()
15 let session = URLSession(
16 configuration: .ephemeral,
17 delegate: delegate,
18 delegateQueue: nil
19 )
20 defer { session.invalidateAndCancel() }
21
22 var hops: [RedirectHop] = []
23 var currentURL = startingURL
24 let maxRedirects = 10
25
26 for step in 1...maxRedirects + 1 {
27 var request = URLRequest(url: currentURL, timeoutInterval: 10)
28 request.httpMethod = "GET"
29
30 let (_, response) = try await session.data(for: request)
31
32 guard let httpResponse = response as? HTTPURLResponse else {
33 throw URLError(.badServerResponse)
34 }
35
36 let statusCode = httpResponse.statusCode
37 let isRedirect = (300...399).contains(statusCode)
38
39 if isRedirect, let location = httpResponse.value(forHTTPHeaderField: "Location") {
40 hops.append(RedirectHop(
41 stepNumber: step,
42 statusCode: statusCode,
43 url: currentURL.absoluteString,
44 isFinal: false
45 ))
46
47 // Resolve relative redirects
48 if let nextURL = URL(string: location, relativeTo: currentURL)?.absoluteURL {
49 currentURL = nextURL
50 } else {
51 break
52 }
53
54 if step > maxRedirects { break }
55 } else {
56 // Non-redirect — this is the final destination
57 hops.append(RedirectHop(
58 stepNumber: step,
59 statusCode: statusCode,
60 url: currentURL.absoluteString,
61 isFinal: true
62 ))
63 break
64 }
65 }
66
67 return hops
68 }
69}
70
71private final class NoRedirectDelegate: NSObject, URLSessionTaskDelegate, @unchecked Sendable {
72 func urlSession(
73 _ session: URLSession,
74 task: URLSessionTask,
75 willPerformHTTPRedirection response: HTTPURLResponse,
76 newRequest request: URLRequest,
77 completionHandler: @escaping (URLRequest?) -> Void
78 ) {
79 // Don't follow redirects automatically — return nil to stop
80 completionHandler(nil)
81 }
82}
DomainDig/ReverseDNSService.swift added +48
@@ -0,0 +1,48 @@
1import Foundation
2
3struct ReverseDNSService {
4 /// Look up the PTR record for an IPv4 address via Cloudflare DoH.
5 static func lookup(ip: String) async -> String? {
6 let octets = ip.split(separator: ".")
7 guard octets.count == 4 else { return nil }
8
9 let reversed = octets.reversed().joined(separator: ".")
10 let ptrDomain = "\(reversed).in-addr.arpa"
11
12 // PTR record type = 12
13 do {
14 let records = try await lookupPTR(domain: ptrDomain)
15 return records.first
16 } catch {
17 return nil
18 }
19 }
20
21 private static func lookupPTR(domain: String) async throws -> [String] {
22 var components = URLComponents(string: "https://cloudflare-dns.com/dns-query")!
23 components.queryItems = [
24 URLQueryItem(name: "name", value: domain),
25 URLQueryItem(name: "type", value: "12") // PTR
26 ]
27
28 var request = URLRequest(url: components.url!)
29 request.setValue("application/dns-json", forHTTPHeaderField: "Accept")
30
31 let (data, response) = try await URLSession.shared.data(for: request)
32
33 guard let httpResponse = response as? HTTPURLResponse,
34 httpResponse.statusCode == 200 else {
35 throw URLError(.badServerResponse)
36 }
37
38 let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data)
39
40 guard let answers = dnsResponse.Answer else {
41 return []
42 }
43
44 return answers
45 .filter { $0.type == 12 }
46 .map { $0.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) }
47 }
48}
DomainDig/SavedDomainsView.swift added +42
@@ -0,0 +1,42 @@
1import SwiftUI
2
3struct SavedDomainsView: View {
4 @Bindable var viewModel: DomainViewModel
5 @Environment(\.dismiss) private var dismiss
6
7 var body: some View {
8 List {
9 if viewModel.savedDomains.isEmpty {
10 Text("No saved domains")
11 .font(.system(.callout, design: .monospaced))
12 .foregroundStyle(.secondary)
13 .listRowBackground(Color(.systemGray6).opacity(0.5))
14 } else {
15 ForEach(viewModel.savedDomains, id: \.self) { domain in
16 Button {
17 viewModel.domain = domain
18 dismiss()
19 viewModel.run()
20 } label: {
21 Text(domain)
22 .font(.system(.callout, design: .monospaced))
23 .foregroundStyle(.primary)
24 }
25 .listRowBackground(Color(.systemGray6).opacity(0.5))
26 }
27 .onDelete { offsets in
28 viewModel.removeSavedDomains(at: offsets)
29 }
30 }
31 }
32 .scrollContentBackground(.hidden)
33 .background(Color.black)
34 .navigationTitle("Saved Domains")
35 .toolbar {
36 if !viewModel.savedDomains.isEmpty {
37 EditButton()
38 }
39 }
40 .preferredColorScheme(.dark)
41 }
42}
claude.md +113 −21
@@ -1,9 +1,11 @@
11# DomainDig – Project Context
22
33## What this is
4An iOS utility app for querying DNS records and inspecting SSL/TLS certificates
5for any domain. Designed for developers, sysadmins, and anyone who manages or
6troubleshoots domains and servers.
4An iOS utility app for querying DNS records, inspecting SSL/TLS certificates,
5checking HTTP headers, measuring TCP reachability, geolocating IP addresses,
6analyzing email security records, tracing redirect chains, performing reverse
7DNS lookups, and scanning common ports for any domain. Designed for developers,
8sysadmins, and anyone who manages or troubleshoots domains and servers.
79
810## Target user
911Technically literate adults — developers, IT/sysadmin, homelab enthusiasts.
@@ -18,6 +20,8 @@ clean and information-dense, not hand-holding.
1820- No onboarding, no tutorials, no splash screens
1921- Fast — results should appear as soon as they're available, not after all
2022 lookups complete
23- Each result section loads independently with its own ProgressView
24- Errors shown inline per-section, never as global alerts
2125
2226## Features
2327
@@ -35,6 +39,10 @@ Display each record type in its own section. Show TTL alongside each result.
3539If a record type returns no results, show "No records found" for that type
3640rather than hiding the section entirely.
3741
42After the apex query, also queries `*.{domain}` for A, AAAA, MX, and TXT.
43Wildcard results are shown as a sub-section beneath each record type, labelled
44with `*.{domain}`. Hidden if no wildcard records are returned.
45
3846### SSL/TLS Certificate Check
3947Connect to the domain on port 443 via URLSession and inspect the server's
4048certificate chain using URLSession delegate methods. Display:
@@ -45,19 +53,63 @@ certificate chain using URLSession delegate methods. Display:
4553- Days until expiry — highlight in red if under 30 days, yellow if under 60
4654- Certificate chain depth
4755
56### HTTP Headers Check
57Fire a HEAD request to `https://{domain}` and display all response headers.
58Header names in cyan, values in primary. Security-relevant headers highlighted
59in yellow: `strict-transport-security`, `x-frame-options`,
60`x-content-type-options`, `content-security-policy`, `referrer-policy`.
61Service: `HTTPHeadersService.swift`.
62
63### Reachability / TCP Latency
64Use `NWConnection` (Network framework) to attempt TCP connections on ports 443
65and 80. Measure time to `.ready` state. Display green/red dot, latency in ms,
66and reachable/unreachable status. Timeout after 5 seconds.
67Service: `ReachabilityService.swift`.
68
69### IP Geolocation with Map
70After DNS A record resolves, take the first IP and query
71`https://ipapi.co/{ip}/json/` for country, region, city, org, lat/lon.
72Display in an "IP Location" section with a SwiftUI `Map` (MapKit) centered on
73the coordinates with a `Marker`. Map height 180pt, `.standard` style.
74Service: `IPGeolocationService.swift`.
75
76### Email Security Analysis
77Parse SPF from existing TXT records (no extra query). Query `_dmarc.{domain}`
78for DMARC and try common DKIM selectors (`default`, `google`, `mail`) via DoH.
79Display green checkmark if found, red ✗ if not. Full record values truncated
80to 80 chars with tap-to-expand. Triggered after DNS completes.
81Service: `EmailSecurityService.swift`.
82
83### Reverse DNS / PTR Lookup
84After DNS A record resolves, construct reverse DNS name (reversed octets +
85`.in-addr.arpa`) and query PTR record via Cloudflare DoH. Displayed inline
86in the DNS Records section below the A record sub-section.
87Service: `ReverseDNSService.swift`.
88
89### Redirect Chain
90Fire HTTP request to `http://{domain}` with redirects disabled. Follow up to
9110 redirects manually, recording each hop's URL and status code. Display step
92number, status code in cyan, URL, and "(final)" on the last hop. Shows
93"No redirects — direct connection" if the first request returns 200.
94Service: `RedirectChainService.swift`.
95
96### Common Port Scanner
97Probe 10 common ports (21/FTP, 22/SSH, 25/SMTP, 80/HTTP, 443/HTTPS,
98587/SMTP-TLS, 3306/MySQL, 5432/PostgreSQL, 8080/HTTP-Alt, 8443/HTTPS-Alt)
99using `NWConnection` with 3-second timeout. All probes run concurrently.
100Green dot for open, grey dot for closed. Closed is expected — no error shown.
101Service: `PortScanService.swift`.
102
48103### Results layout
49104- Domain input at the top — large text field, keyboard shows on launch
50- Run button to trigger both DNS and SSL lookups simultaneously
51- DNS and SSL results displayed in clearly separated sections below
52- Each section loads independently — don't block SSL results waiting for DNS
53 or vice versa
54
55## Technical constraints
56- SwiftUI, iOS only
57- Fully offline except for DNS-over-HTTPS requests and SSL connections
58- No accounts, no analytics, no ads
59- No third-party dependencies — URLSession and Network framework only
60- Targets latest iOS
105- Run button to trigger all lookups simultaneously
106- Results displayed in clearly separated sections:
107 Reachability → Redirect Chain → DNS Records (with PTR inline) →
108 Email Security → SSL/TLS Certificate → HTTP Headers → IP Location →
109 Open Ports
110- Each section loads independently — don't block one section waiting for another
111- Email security, PTR, and IP geolocation are chained after DNS; all others
112 run in parallel
61113
62114### Recent searches
63115Store 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
65117recent domain populates the text field and runs the lookup immediately. Include
66118a "Clear" button to wipe history. Most recent at the top.
67119
120### Saved domains
121Bookmark button (SF Symbol: `bookmark` / `bookmark.fill`) in the results
122toolbar area, next to the share button. Tapping saves/unsaves the current
123domain. Filled icon when saved. Saved domains viewable from a toolbar button
124that pushes to `SavedDomainsView` — a list of saved domains, tappable to run
125lookups, with swipe-to-delete and an Edit button for bulk deletion.
126Persisted in UserDefaults under key `savedDomains`.
127
128### Lookup history with cached results
129After each successful lookup, a snapshot of all results (DNS, SSL, HTTP headers,
130reachability, geolocation, email security, PTR, redirect chain, port scan) is
131saved to history in UserDefaults as JSON. Capped at 50 entries. `HistoryView`
132shows past lookups with domain and timestamp. Tapping shows full cached results
133in `HistoryDetailView` using the same layout, labelled as cached with the
134original timestamp. Model: `HistoryEntry` (Codable).
135
68136### Share / export
69137A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the
70results area. Formats the full DNS and SSL results as plain text and presents
71the iOS share sheet via ShareLink or UIActivityViewController. The export
72should include the domain, timestamp, all DNS records with TTLs, and all SSL
73cert fields.
138results area. Formats the full results as plain text and presents the iOS share
139sheet via UIActivityViewController. The export includes: domain, timestamp,
140reachability, redirect chain, all DNS records with TTLs and PTR, email security
141records, SSL cert fields, HTTP headers, IP geolocation data, and port scan
142results.
143
144## Technical constraints
145- SwiftUI, iOS only
146- Fully offline except for network requests (DNS-over-HTTPS, SSL, HTTP HEAD,
147 TCP connections, geolocation API)
148- No accounts, no analytics, no ads
149- No third-party dependencies — URLSession, Network, MapKit only
150- Targets latest iOS
151
152## Architecture
153- `Models.swift` — All data models (DNS, SSL, HTTP headers, reachability,
154 geolocation, history entry), all Codable for persistence
155- `DomainViewModel.swift` — `@Observable` view model orchestrating all lookups,
156 managing state, history, saved domains, recent searches, and export
157- `ContentView.swift` — Main screen with input, all result sections, toolbar
158- `SavedDomainsView.swift` — Saved domains list with edit/delete
159- `HistoryView.swift` — History list + `HistoryDetailView` for cached results
160- Services: `DNSLookupService`, `SSLCheckService`, `HTTPHeadersService`,
161 `ReachabilityService`, `IPGeolocationService`, `EmailSecurityService`,
162 `ReverseDNSService`, `RedirectChainService`, `PortScanService`
74163
75164## What good looks like
76165A developer pastes a domain, taps run, and within a couple of seconds sees a
77clean breakdown of every DNS record type and the full SSL cert status. It should
78feel like a native, polished version of running `dig` and `openssl s_client`
79from the terminal.
166clean breakdown of TCP reachability, redirect chain, every DNS record type with
167reverse DNS, email security posture, the full SSL cert status, HTTP response
168headers with security headers highlighted, IP geolocation with a map, and a
169port scan of common services. It should feel like a native, polished version of
170running `dig`, `openssl s_client`, `curl -I`, `nmap`, and `whois` from the
171terminal.