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 @@
10 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; 10 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; };
11/* End PBXFileReference section */ 11/* End PBXFileReference section */
12 12
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
13/* Begin PBXFileSystemSynchronizedRootGroup section */ 23/* Begin PBXFileSystemSynchronizedRootGroup section */
14 8B78006B2F6090E300933221 /* DomainDig */ = { 24 8B78006B2F6090E300933221 /* DomainDig */ = {
15 isa = PBXFileSystemSynchronizedRootGroup; 25 isa = PBXFileSystemSynchronizedRootGroup;
26 exceptions = (
27 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */,
28 );
16 path = DomainDig; 29 path = DomainDig;
17 sourceTree = "<group>"; 30 sourceTree = "<group>";
18 }; 31 };
@@ -252,10 +265,11 @@
252 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 265 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
253 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 266 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
254 CODE_SIGN_STYLE = Automatic; 267 CODE_SIGN_STYLE = Automatic;
255 CURRENT_PROJECT_VERSION = 1; 268 CURRENT_PROJECT_VERSION = 3;
256 DEVELOPMENT_TEAM = ZCNAX3VL9D; 269 DEVELOPMENT_TEAM = ZCNAX3VL9D;
257 ENABLE_PREVIEWS = YES; 270 ENABLE_PREVIEWS = YES;
258 GENERATE_INFOPLIST_FILE = YES; 271 GENERATE_INFOPLIST_FILE = YES;
272 INFOPLIST_FILE = DomainDig/Info.plist;
259 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig"; 273 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
260 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 274 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
261 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 275 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -268,7 +282,7 @@
268 "$(inherited)", 282 "$(inherited)",
269 "@executable_path/Frameworks", 283 "@executable_path/Frameworks",
270 ); 284 );
271 MARKETING_VERSION = 1.0; 285 MARKETING_VERSION = 1.2;
272 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 286 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
273 PRODUCT_NAME = "$(TARGET_NAME)"; 287 PRODUCT_NAME = "$(TARGET_NAME)";
274 STRING_CATALOG_GENERATE_SYMBOLS = YES; 288 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -287,10 +301,11 @@
287 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 301 ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
288 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 302 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
289 CODE_SIGN_STYLE = Automatic; 303 CODE_SIGN_STYLE = Automatic;
290 CURRENT_PROJECT_VERSION = 1; 304 CURRENT_PROJECT_VERSION = 3;
291 DEVELOPMENT_TEAM = ZCNAX3VL9D; 305 DEVELOPMENT_TEAM = ZCNAX3VL9D;
292 ENABLE_PREVIEWS = YES; 306 ENABLE_PREVIEWS = YES;
293 GENERATE_INFOPLIST_FILE = YES; 307 GENERATE_INFOPLIST_FILE = YES;
308 INFOPLIST_FILE = DomainDig/Info.plist;
294 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig"; 309 INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig";
295 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; 310 INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
296 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 311 INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@@ -303,7 +318,7 @@
303 "$(inherited)", 318 "$(inherited)",
304 "@executable_path/Frameworks", 319 "@executable_path/Frameworks",
305 ); 320 );
306 MARKETING_VERSION = 1.0; 321 MARKETING_VERSION = 1.2;
307 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; 322 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig;
308 PRODUCT_NAME = "$(TARGET_NAME)"; 323 PRODUCT_NAME = "$(TARGET_NAME)";
309 STRING_CATALOG_GENERATE_SYMBOLS = YES; 324 STRING_CATALOG_GENERATE_SYMBOLS = YES;
DomainDig/ContentView.swift +383 −19
@@ -1,4 +1,5 @@
1import SwiftUI 1import SwiftUI
2import MapKit
2 3
3struct ContentView: View { 4struct ContentView: View {
4 @State private var viewModel = DomainViewModel() 5 @State private var viewModel = DomainViewModel()
@@ -10,21 +11,15 @@ struct ContentView: View {
10 VStack(spacing: 0) { 11 VStack(spacing: 0) {
11 inputSection 12 inputSection
12 if viewModel.hasRun { 13 if viewModel.hasRun {
13 if viewModel.resultsLoaded { 14 actionButtons
14 HStack { 15 reachabilitySection
15 Spacer() 16 redirectChainSection
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 }
26 dnsResultsSection 17 dnsResultsSection
18 emailSecuritySection
27 sslResultsSection 19 sslResultsSection
20 httpHeadersSection
21 ipGeolocationSection
22 portScanSection
28 } else if !viewModel.recentSearches.isEmpty { 23 } else if !viewModel.recentSearches.isEmpty {
29 recentSearchesSection 24 recentSearchesSection
30 } 25 }
@@ -36,6 +31,30 @@ struct ContentView: View {
36 .navigationTitle("DomainDig") 31 .navigationTitle("DomainDig")
37 .toolbarColorScheme(.dark, for: .navigationBar) 32 .toolbarColorScheme(.dark, for: .navigationBar)
38 .preferredColorScheme(.dark) 33 .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 }
39 } 58 }
40 .onAppear { 59 .onAppear {
41 domainFieldFocused = true 60 domainFieldFocused = true
@@ -72,6 +91,31 @@ struct ContentView: View {
72 .padding(.vertical, 16) 91 .padding(.vertical, 16)
73 } 92 }
74 93
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
75 // MARK: - Recent Searches 119 // MARK: - Recent Searches
76 120
77 private var recentSearchesSection: some View { 121 private var recentSearchesSection: some View {
@@ -108,6 +152,110 @@ struct ContentView: View {
108 .padding(.top, 8) 152 .padding(.top, 8)
109 } 153 }
110 154
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
111 // MARK: - DNS Results 259 // MARK: - DNS Results
112 260
113 private var dnsResultsSection: some View { 261 private var dnsResultsSection: some View {
@@ -123,10 +271,40 @@ struct ContentView: View {
123 } else { 271 } else {
124 ForEach(viewModel.dnsSections) { section in 272 ForEach(viewModel.dnsSections) { section in
125 dnsRecordSection(section) 273 dnsRecordSection(section)
274 if section.recordType == .A {
275 ptrRow
276 }
126 } 277 }
127 } 278 }
128 } 279 }
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)
130 } 308 }
131 309
132 private func dnsRecordSection(_ section: DNSSection) -> some View { 310 private func dnsRecordSection(_ section: DNSSection) -> some View {
@@ -146,7 +324,6 @@ struct ContentView: View {
146 dnsRecordRows(section.records) 324 dnsRecordRows(section.records)
147 } 325 }
148 326
149 // Wildcard sub-section (only shown when records exist)
150 if !section.wildcardRecords.isEmpty { 327 if !section.wildcardRecords.isEmpty {
151 Text("*.\(viewModel.searchedDomain)") 328 Text("*.\(viewModel.searchedDomain)")
152 .font(.system(.caption, design: .monospaced)) 329 .font(.system(.caption, design: .monospaced))
@@ -177,6 +354,66 @@ struct ContentView: View {
177 } 354 }
178 } 355 }
179 356
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
180 // MARK: - SSL Results 417 // MARK: - SSL Results
181 418
182 private var sslResultsSection: some View { 419 private var sslResultsSection: some View {
@@ -201,7 +438,6 @@ struct ContentView: View {
201 certRow("Common Name", info.commonName) 438 certRow("Common Name", info.commonName)
202 certRow("Issuer", info.issuer) 439 certRow("Issuer", info.issuer)
203 440
204 // SANs
205 VStack(alignment: .leading, spacing: 2) { 441 VStack(alignment: .leading, spacing: 2) {
206 Text("SANs") 442 Text("SANs")
207 .font(.system(.caption2, design: .monospaced)) 443 .font(.system(.caption2, design: .monospaced))
@@ -235,6 +471,136 @@ struct ContentView: View {
235 .cornerRadius(6) 471 .cornerRadius(6)
236 } 472 }
237 473
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
238 // MARK: - Helpers 604 // MARK: - Helpers
239 605
240 private func sectionHeader(_ title: String) -> some View { 606 private func sectionHeader(_ title: String) -> some View {
@@ -269,8 +635,6 @@ struct ContentView: View {
269 635
270 private func shareResults() { 636 private func shareResults() {
271 let text = viewModel.exportText() 637 let text = viewModel.exportText()
272
273 // Write to a named temp file so "Save to Files" uses a proper filename
274 let dateFmt = DateFormatter() 638 let dateFmt = DateFormatter()
275 dateFmt.dateFormat = "yyyyMMdd_HHmmss" 639 dateFmt.dateFormat = "yyyyMMdd_HHmmss"
276 let timestamp = dateFmt.string(from: Date()) 640 let timestamp = dateFmt.string(from: Date())
@@ -295,7 +659,7 @@ struct ContentView: View {
295 } 659 }
296} 660}
297 661
298private extension DateFormatter { 662extension DateFormatter {
299 static let certDate: DateFormatter = { 663 static let certDate: DateFormatter = {
300 let f = DateFormatter() 664 let f = DateFormatter()
301 f.dateStyle = .medium 665 f.dateStyle = .medium
DomainDig/DNSLookupService.swift +5 −4
@@ -35,15 +35,16 @@ struct DNSLookupService {
35 } 35 }
36 } 36 }
37 37
38 /// Record types that support wildcard queries.
39 private nonisolated(unsafe) static let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
40
41 static func lookupAll(domain: String) async -> [DNSSection] { 38 static func lookupAll(domain: String) async -> [DNSSection] {
42 // Each task returns (recordType, apex records, wildcard records). 39 // Each task returns (recordType, apex records, wildcard records).
43 typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?) 40 typealias Result = (type: DNSRecordType, records: [DNSRecord], wildcard: [DNSRecord], error: String?)
44 41
42 // Record types that support wildcard queries
43 let wildcardTypes: Set<DNSRecordType> = [.A, .AAAA, .MX, .TXT]
44
45 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in 45 return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in
46 for recordType in DNSRecordType.allCases { 46 for recordType in DNSRecordType.allCases {
47 let shouldQueryWildcard = wildcardTypes.contains(recordType)
47 group.addTask { 48 group.addTask {
48 var apexRecords: [DNSRecord] = [] 49 var apexRecords: [DNSRecord] = []
49 var wildcardRecords: [DNSRecord] = [] 50 var wildcardRecords: [DNSRecord] = []
@@ -57,7 +58,7 @@ struct DNSLookupService {
57 } 58 }
58 59
59 // Wildcard query (only for applicable types, and only if apex didn't fail) 60 // 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 {
61 do { 62 do {
62 wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType) 63 wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType)
63 } catch { 64 } catch {
DomainDig/DomainViewModel.swift +418 −10
@@ -6,22 +6,135 @@ import SwiftUI
6final class DomainViewModel { 6final class DomainViewModel {
7 var domain: String = "" 7 var domain: String = ""
8 8
9 // DNS
9 var dnsSections: [DNSSection] = [] 10 var dnsSections: [DNSSection] = []
10 var dnsLoading = false 11 var dnsLoading = false
11 var dnsError: String? 12 var dnsError: String?
12 13
14 // SSL
13 var sslInfo: SSLCertificateInfo? 15 var sslInfo: SSLCertificateInfo?
14 var sslLoading = false 16 var sslLoading = false
15 var sslError: String? 17 var sslError: String?
16 18
19 // HTTP Headers
20 var httpHeaders: [HTTPHeader] = []
21 var httpHeadersLoading = false
22 var httpHeadersError: String?
23
24 // Reachability
25 var reachabilityResults: [PortReachability] = []
26 var reachabilityLoading = false
27 var reachabilityError: String?
28
29 // IP Geolocation
30 var ipGeolocation: IPGeolocation?
31 var ipGeolocationLoading = false
32 var ipGeolocationError: String?
33
34 // Email Security
35 var emailSecurity: EmailSecurityResult?
36 var emailSecurityLoading = false
37 var emailSecurityError: String?
38
39 // PTR / Reverse DNS
40 var ptrRecord: String?
41 var ptrLoading = false
42 var ptrError: String?
43
44 // Redirect Chain
45 var redirectChain: [RedirectHop] = []
46 var redirectChainLoading = false
47 var redirectChainError: String?
48
49 // Port Scan
50 var portScanResults: [PortScanResult] = []
51 var portScanLoading = false
52 var portScanError: String?
53
17 var hasRun = false 54 var hasRun = false
18 private(set) var searchedDomain: String = "" 55 private(set) var searchedDomain: String = ""
19 56
57 // MARK: - Recent Searches
58
20 private static let recentSearchesKey = "recentSearches" 59 private static let recentSearchesKey = "recentSearches"
21 private static let maxRecent = 20 60 private static let maxRecent = 20
22 61
23 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] 62 var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? []
24 63
64 // MARK: - Saved Domains
65
66 private static let savedDomainsKey = "savedDomains"
67
68 var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? []
69
70 var isCurrentDomainSaved: Bool {
71 !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() })
72 }
73
74 func toggleSavedDomain() {
75 if isCurrentDomainSaved {
76 savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() }
77 } else {
78 savedDomains.append(searchedDomain)
79 }
80 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
81 }
82
83 func removeSavedDomain(_ domain: String) {
84 savedDomains.removeAll { $0 == domain }
85 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
86 }
87
88 func removeSavedDomains(at offsets: IndexSet) {
89 savedDomains.remove(atOffsets: offsets)
90 UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey)
91 }
92
93 // MARK: - History
94
95 private static let historyKey = "lookupHistory"
96 private static let maxHistory = 50
97
98 var history: [HistoryEntry] = {
99 guard let data = UserDefaults.standard.data(forKey: "lookupHistory"),
100 let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else {
101 return []
102 }
103 return entries
104 }()
105
106 private func saveHistoryEntry() {
107 let entry = HistoryEntry(
108 domain: searchedDomain,
109 timestamp: Date(),
110 dnsSections: dnsSections,
111 sslInfo: sslInfo,
112 httpHeaders: httpHeaders,
113 reachabilityResults: reachabilityResults,
114 ipGeolocation: ipGeolocation,
115 emailSecurity: emailSecurity,
116 ptrRecord: ptrRecord,
117 redirectChain: redirectChain,
118 portScanResults: portScanResults
119 )
120 history.insert(entry, at: 0)
121 if history.count > Self.maxHistory {
122 history = Array(history.prefix(Self.maxHistory))
123 }
124 if let data = try? JSONEncoder().encode(history) {
125 UserDefaults.standard.set(data, forKey: Self.historyKey)
126 }
127 }
128
129 func removeHistoryEntries(at offsets: IndexSet) {
130 history.remove(atOffsets: offsets)
131 if let data = try? JSONEncoder().encode(history) {
132 UserDefaults.standard.set(data, forKey: Self.historyKey)
133 }
134 }
135
136 // MARK: - Computed
137
25 var trimmedDomain: String { 138 var trimmedDomain: String {
26 domain 139 domain
27 .trimmingCharacters(in: .whitespacesAndNewlines) 140 .trimmingCharacters(in: .whitespacesAndNewlines)
@@ -30,6 +143,49 @@ final class DomainViewModel {
30 .components(separatedBy: "/").first ?? "" 143 .components(separatedBy: "/").first ?? ""
31 } 144 }
32 145
146 /// True when all lookups have finished (regardless of success/failure).
147 var resultsLoaded: Bool {
148 hasRun && !dnsLoading && !sslLoading && !httpHeadersLoading && !reachabilityLoading
149 && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading
150 && !redirectChainLoading && !portScanLoading
151 }
152
153 // MARK: - Reset
154
155 func reset() {
156 hasRun = false
157 searchedDomain = ""
158 dnsSections = []
159 dnsError = nil
160 dnsLoading = false
161 sslInfo = nil
162 sslError = nil
163 sslLoading = false
164 httpHeaders = []
165 httpHeadersError = nil
166 httpHeadersLoading = false
167 reachabilityResults = []
168 reachabilityError = nil
169 reachabilityLoading = false
170 ipGeolocation = nil
171 ipGeolocationError = nil
172 ipGeolocationLoading = false
173 emailSecurity = nil
174 emailSecurityError = nil
175 emailSecurityLoading = false
176 ptrRecord = nil
177 ptrError = nil
178 ptrLoading = false
179 redirectChain = []
180 redirectChainError = nil
181 redirectChainLoading = false
182 portScanResults = []
183 portScanError = nil
184 portScanLoading = false
185 }
186
187 // MARK: - Run
188
33 func run() { 189 func run() {
34 let target = trimmedDomain 190 let target = trimmedDomain
35 guard !target.isEmpty else { return } 191 guard !target.isEmpty else { return }
@@ -37,25 +193,77 @@ final class DomainViewModel {
37 addRecentSearch(target) 193 addRecentSearch(target)
38 searchedDomain = target 194 searchedDomain = target
39 hasRun = true 195 hasRun = true
196
197 // Reset all state
40 dnsSections = [] 198 dnsSections = []
41 dnsError = nil 199 dnsError = nil
42 dnsLoading = true 200 dnsLoading = true
43 sslInfo = nil 201 sslInfo = nil
44 sslError = nil 202 sslError = nil
45 sslLoading = true 203 sslLoading = true
204 httpHeaders = []
205 httpHeadersError = nil
206 httpHeadersLoading = true
207 reachabilityResults = []
208 reachabilityError = nil
209 reachabilityLoading = true
210 ipGeolocation = nil
211 ipGeolocationError = nil
212 ipGeolocationLoading = true
213 emailSecurity = nil
214 emailSecurityError = nil
215 emailSecurityLoading = true
216 ptrRecord = nil
217 ptrError = nil
218 ptrLoading = true
219 redirectChain = []
220 redirectChainError = nil
221 redirectChainLoading = true
222 portScanResults = []
223 portScanError = nil
224 portScanLoading = true
46 225
47 Task { 226 Task {
48 await withTaskGroup(of: Void.self) { group in 227 await withTaskGroup(of: Void.self) { group in
228 // DNS → chained: email security, PTR, geolocation
49 group.addTask { @MainActor in 229 group.addTask { @MainActor in
50 await self.runDNS(domain: target) 230 await self.runDNS(domain: target)
231 // These depend on DNS results and run in parallel after DNS
232 await withTaskGroup(of: Void.self) { postDNS in
233 postDNS.addTask { @MainActor in
234 await self.runEmailSecurity(domain: target)
235 }
236 postDNS.addTask { @MainActor in
237 await self.runReverseDNS()
238 }
239 postDNS.addTask { @MainActor in
240 await self.runIPGeolocation()
241 }
242 }
51 } 243 }
52 group.addTask { @MainActor in 244 group.addTask { @MainActor in
53 await self.runSSL(domain: target) 245 await self.runSSL(domain: target)
54 } 246 }
247 group.addTask { @MainActor in
248 await self.runHTTPHeaders(domain: target)
249 }
250 group.addTask { @MainActor in
251 await self.runReachability(domain: target)
252 }
253 group.addTask { @MainActor in
254 await self.runRedirectChain(domain: target)
255 }
256 group.addTask { @MainActor in
257 await self.runPortScan(domain: target)
258 }
55 } 259 }
260 // Save history after all lookups complete so the snapshot is complete
261 saveHistoryEntry()
56 } 262 }
57 } 263 }
58 264
265 // MARK: - Lookup Methods
266
59 private func runDNS(domain: String) async { 267 private func runDNS(domain: String) async {
60 do { 268 do {
61 let sections = await DNSLookupService.lookupAll(domain: domain) 269 let sections = await DNSLookupService.lookupAll(domain: domain)
@@ -74,27 +282,157 @@ final class DomainViewModel {
74 sslLoading = false 282 sslLoading = false
75 } 283 }
76 284
77 /// True when both lookups have finished (regardless of success/failure). 285 private func runHTTPHeaders(domain: String) async {
78 var resultsLoaded: Bool { 286 do {
79 hasRun && !dnsLoading && !sslLoading 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
80 } 355 }
81 356
82 // MARK: - Export 357 // MARK: - Export
83 358
84 func exportText() -> String { 359 func exportText() -> String {
360 return Self.formatExportText(
361 domain: searchedDomain,
362 date: Date(),
363 dnsSections: dnsSections,
364 sslInfo: sslInfo,
365 sslError: sslError,
366 httpHeaders: httpHeaders,
367 httpHeadersError: httpHeadersError,
368 reachabilityResults: reachabilityResults,
369 ipGeolocation: ipGeolocation,
370 ipGeolocationError: ipGeolocationError,
371 emailSecurity: emailSecurity,
372 ptrRecord: ptrRecord,
373 redirectChain: redirectChain,
374 portScanResults: portScanResults
375 )
376 }
377
378 static func formatExportText(
379 domain: String,
380 date: Date,
381 dnsSections: [DNSSection],
382 sslInfo: SSLCertificateInfo?,
383 sslError: String? = nil,
384 httpHeaders: [HTTPHeader],
385 httpHeadersError: String? = nil,
386 reachabilityResults: [PortReachability],
387 ipGeolocation: IPGeolocation?,
388 ipGeolocationError: String? = nil,
389 emailSecurity: EmailSecurityResult? = nil,
390 ptrRecord: String? = nil,
391 redirectChain: [RedirectHop] = [],
392 portScanResults: [PortScanResult] = []
393 ) -> String {
85 let dateFmt = DateFormatter() 394 let dateFmt = DateFormatter()
86 dateFmt.dateFormat = "yyyy-MM-dd HH:mm" 395 dateFmt.dateFormat = "yyyy-MM-dd HH:mm"
87 let now = dateFmt.string(from: Date())
88 396
89 var lines: [String] = [ 397 var lines: [String] = [
90 "DomainDig Export", 398 "DomainDig Export",
91 "Domain: \(searchedDomain)", 399 "Domain: \(domain)",
92 "Date: \(now)", 400 "Date: \(dateFmt.string(from: date))",
93 "",
94 "DNS Records",
95 "-----------"
96 ] 401 ]
97 402
403 // Reachability
404 if !reachabilityResults.isEmpty {
405 lines.append("")
406 lines.append("Reachability")
407 lines.append("------------")
408 for result in reachabilityResults {
409 if result.reachable, let ms = result.latencyMs {
410 lines.append(" Port \(result.port) \(ms)ms Reachable")
411 } else {
412 lines.append(" Port \(result.port) — Unreachable")
413 }
414 }
415 }
416
417 // Redirect Chain
418 if !redirectChain.isEmpty {
419 lines.append("")
420 lines.append("Redirect Chain")
421 lines.append("--------------")
422 if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) {
423 lines.append(" No redirects — direct connection")
424 } else {
425 for hop in redirectChain {
426 let final = hop.isFinal ? " (final)" : ""
427 lines.append(" \(hop.stepNumber) \(hop.statusCode) \(hop.url)\(final)")
428 }
429 }
430 }
431
432 // DNS
433 lines.append("")
434 lines.append("DNS Records")
435 lines.append("-----------")
98 for section in dnsSections { 436 for section in dnsSections {
99 lines.append(section.recordType.rawValue) 437 lines.append(section.recordType.rawValue)
100 if let error = section.error { 438 if let error = section.error {
@@ -107,13 +445,30 @@ final class DomainViewModel {
107 } 445 }
108 } 446 }
109 if !section.wildcardRecords.isEmpty { 447 if !section.wildcardRecords.isEmpty {
110 lines.append("*.\(searchedDomain)") 448 lines.append("*.\(domain)")
111 for record in section.wildcardRecords { 449 for record in section.wildcardRecords {
112 lines.append(" \(record.value) TTL \(record.ttl)") 450 lines.append(" \(record.value) TTL \(record.ttl)")
113 } 451 }
114 } 452 }
115 } 453 }
116 454
455 // PTR
456 if let ptr = ptrRecord {
457 lines.append("PTR (Reverse DNS)")
458 lines.append(" \(ptr)")
459 }
460
461 // Email Security
462 if let email = emailSecurity {
463 lines.append("")
464 lines.append("Email Security")
465 lines.append("--------------")
466 lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")")
467 lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")")
468 lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(email.dkim.value ?? "No record found")")
469 }
470
471 // SSL
117 if let info = sslInfo { 472 if let info = sslInfo {
118 let certDateFmt = DateFormatter() 473 let certDateFmt = DateFormatter()
119 certDateFmt.dateStyle = .medium 474 certDateFmt.dateStyle = .medium
@@ -136,6 +491,59 @@ final class DomainViewModel {
136 lines.append("Error: \(error)") 491 lines.append("Error: \(error)")
137 } 492 }
138 493
494 // HTTP Headers
495 if !httpHeaders.isEmpty {
496 lines.append("")
497 lines.append("HTTP Headers")
498 lines.append("------------")
499 for header in httpHeaders {
500 lines.append(" \(header.name): \(header.value)")
501 }
502 } else if let error = httpHeadersError {
503 lines.append("")
504 lines.append("HTTP Headers")
505 lines.append("------------")
506 lines.append("Error: \(error)")
507 }
508
509 // IP Geolocation
510 if let geo = ipGeolocation {
511 lines.append("")
512 lines.append("IP Location")
513 lines.append("-----------")
514 lines.append("IP: \(geo.ip)")
515 if let org = geo.org { lines.append("Org: \(org)") }
516 let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ")
517 if !location.isEmpty { lines.append("Location: \(location)") }
518 if let lat = geo.latitude, let lon = geo.longitude {
519 lines.append("Coordinates: \(lat), \(lon)")
520 }
521 } else if let error = ipGeolocationError, error != "No A record available" {
522 lines.append("")
523 lines.append("IP Location")
524 lines.append("-----------")
525 lines.append("Error: \(error)")
526 }
527
528 // Open Ports
529 if !portScanResults.isEmpty {
530 lines.append("")
531 lines.append("Open Ports")
532 lines.append("----------")
533 let openPorts = portScanResults.filter { $0.open }
534 if openPorts.isEmpty {
535 lines.append(" No open ports detected")
536 } else {
537 for port in openPorts {
538 lines.append(" \(port.port) \(port.service)")
539 }
540 }
541 let closedPorts = portScanResults.filter { !$0.open }
542 if !closedPorts.isEmpty {
543 lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))")
544 }
545 }
546
139 return lines.joined(separator: "\n") 547 return lines.joined(separator: "\n")
140 } 548 }
141 549
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
2 2
3// MARK: - DNS Models 3// MARK: - DNS Models
4 4
5enum DNSRecordType: String, CaseIterable { 5enum DNSRecordType: String, CaseIterable, Codable {
6 case A 6 case A
7 case AAAA 7 case AAAA
8 case MX 8 case MX
@@ -22,14 +22,14 @@ enum DNSRecordType: String, CaseIterable {
22 } 22 }
23} 23}
24 24
25struct DNSRecord: Identifiable { 25struct DNSRecord: Identifiable, Codable {
26 let id = UUID() 26 var id = UUID()
27 let value: String 27 let value: String
28 let ttl: Int 28 let ttl: Int
29} 29}
30 30
31struct DNSSection: Identifiable { 31struct DNSSection: Identifiable, Codable {
32 let id = UUID() 32 var id = UUID()
33 let recordType: DNSRecordType 33 let recordType: DNSRecordType
34 var records: [DNSRecord] 34 var records: [DNSRecord]
35 var wildcardRecords: [DNSRecord] = [] 35 var wildcardRecords: [DNSRecord] = []
@@ -38,7 +38,7 @@ struct DNSSection: Identifiable {
38 38
39// MARK: - SSL Models 39// MARK: - SSL Models
40 40
41struct SSLCertificateInfo { 41struct SSLCertificateInfo: Codable {
42 let commonName: String 42 let commonName: String
43 let subjectAltNames: [String] 43 let subjectAltNames: [String]
44 let issuer: String 44 let issuer: String
@@ -48,6 +48,130 @@ struct SSLCertificateInfo {
48 let chainDepth: Int 48 let chainDepth: Int
49} 49}
50 50
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
51// MARK: - Cloudflare DNS-over-HTTPS Response 175// MARK: - Cloudflare DNS-over-HTTPS Response
52 176
53struct CloudflareDNSResponse: Decodable { 177struct 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 @@
1# DomainDig – Project Context 1# DomainDig – Project Context
2 2
3## What this is 3## What this is
4An iOS utility app for querying DNS records and inspecting SSL/TLS certificates 4An iOS utility app for querying DNS records, inspecting SSL/TLS certificates,
5for any domain. Designed for developers, sysadmins, and anyone who manages or 5checking HTTP headers, measuring TCP reachability, geolocating IP addresses,
6troubleshoots domains and servers. 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.
7 9
8## Target user 10## Target user
9Technically literate adults — developers, IT/sysadmin, homelab enthusiasts. 11Technically literate adults — developers, IT/sysadmin, homelab enthusiasts.
@@ -18,6 +20,8 @@ clean and information-dense, not hand-holding.
18- No onboarding, no tutorials, no splash screens 20- No onboarding, no tutorials, no splash screens
19- Fast — results should appear as soon as they're available, not after all 21- Fast — results should appear as soon as they're available, not after all
20 lookups complete 22 lookups complete
23- Each result section loads independently with its own ProgressView
24- Errors shown inline per-section, never as global alerts
21 25
22## Features 26## Features
23 27
@@ -35,6 +39,10 @@ Display each record type in its own section. Show TTL alongside each result.
35If a record type returns no results, show "No records found" for that type 39If a record type returns no results, show "No records found" for that type
36rather than hiding the section entirely. 40rather than hiding the section entirely.
37 41
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
38### SSL/TLS Certificate Check 46### SSL/TLS Certificate Check
39Connect to the domain on port 443 via URLSession and inspect the server's 47Connect to the domain on port 443 via URLSession and inspect the server's
40certificate chain using URLSession delegate methods. Display: 48certificate chain using URLSession delegate methods. Display:
@@ -45,19 +53,63 @@ certificate chain using URLSession delegate methods. Display:
45- Days until expiry — highlight in red if under 30 days, yellow if under 60 53- Days until expiry — highlight in red if under 30 days, yellow if under 60
46- Certificate chain depth 54- Certificate chain depth
47 55
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
48### Results layout 103### Results layout
49- Domain input at the top — large text field, keyboard shows on launch 104- Domain input at the top — large text field, keyboard shows on launch
50- Run button to trigger both DNS and SSL lookups simultaneously 105- Run button to trigger all lookups simultaneously
51- DNS and SSL results displayed in clearly separated sections below 106- Results displayed in clearly separated sections:
52- Each section loads independently — don't block SSL results waiting for DNS 107 Reachability → Redirect Chain → DNS Records (with PTR inline) →
53 or vice versa 108 Email Security → SSL/TLS Certificate → HTTP Headers → IP Location →
54 109 Open Ports
55## Technical constraints 110- Each section loads independently — don't block one section waiting for another
56- SwiftUI, iOS only 111- Email security, PTR, and IP geolocation are chained after DNS; all others
57- Fully offline except for DNS-over-HTTPS requests and SSL connections 112 run in parallel
58- No accounts, no analytics, no ads
59- No third-party dependencies — URLSession and Network framework only
60- Targets latest iOS
61 113
62### Recent searches 114### Recent searches
63Store the last 20 searched domains locally using UserDefaults. Display them as 115Store 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
65recent domain populates the text field and runs the lookup immediately. Include 117recent domain populates the text field and runs the lookup immediately. Include
66a "Clear" button to wipe history. Most recent at the top. 118a "Clear" button to wipe history. Most recent at the top.
67 119
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
68### Share / export 136### Share / export
69A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the 137A 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 138results area. Formats the full results as plain text and presents the iOS share
71the iOS share sheet via ShareLink or UIActivityViewController. The export 139sheet via UIActivityViewController. The export includes: domain, timestamp,
72should include the domain, timestamp, all DNS records with TTLs, and all SSL 140reachability, redirect chain, all DNS records with TTLs and PTR, email security
73cert fields. 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`
74 163
75## What good looks like 164## What good looks like
76A developer pastes a domain, taps run, and within a couple of seconds sees a 165A 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 166clean breakdown of TCP reachability, redirect chain, every DNS record type with
78feel like a native, polished version of running `dig` and `openssl s_client` 167reverse DNS, email security posture, the full SSL cert status, HTTP response
79from the terminal. 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.