Commit 376e7b55b4
Verified · cmc
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 @@ | |||
| 1 | import SwiftUI | 1 | import SwiftUI |
| 2 | import MapKit | ||
| 2 | 3 | ||
| 3 | struct ContentView: View { | 4 | struct 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 | ||
| 298 | private extension DateFormatter { | 662 | extension 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 | |||
| 6 | final class DomainViewModel { | 6 | final 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | import MapKit | ||
| 3 | |||
| 4 | struct 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 | |||
| 57 | struct 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct 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 NSAllowsArbitraryLoads) </key> | ||
| 6 | <true/> | ||
| 7 | <key>App Transport Security Settings (or NSAppTransportSecurity)</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 | ||
| 5 | enum DNSRecordType: String, CaseIterable { | 5 | enum 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 | ||
| 25 | struct DNSRecord: Identifiable { | 25 | struct 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 | ||
| 31 | struct DNSSection: Identifiable { | 31 | struct 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 | ||
| 41 | struct SSLCertificateInfo { | 41 | struct 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 | |||
| 53 | struct 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 | |||
| 73 | struct 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 | |||
| 82 | struct 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 | |||
| 94 | struct EmailSecurityResult: Codable { | ||
| 95 | let spf: EmailSecurityRecord | ||
| 96 | let dmarc: EmailSecurityRecord | ||
| 97 | let dkim: EmailSecurityRecord | ||
| 98 | } | ||
| 99 | |||
| 100 | struct EmailSecurityRecord: Codable { | ||
| 101 | let found: Bool | ||
| 102 | let value: String? | ||
| 103 | } | ||
| 104 | |||
| 105 | // MARK: - Redirect Chain Models | ||
| 106 | |||
| 107 | struct 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 | |||
| 117 | struct 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 | |||
| 126 | struct 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 | ||
| 53 | struct CloudflareDNSResponse: Decodable { | 177 | struct CloudflareDNSResponse: Decodable { |
DomainDig/PortScanService.swift added +93
| @@ -0,0 +1,93 @@ | |||
| 1 | import Foundation | ||
| 2 | import Network | ||
| 3 | |||
| 4 | struct 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 | |||
| 70 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Network | ||
| 3 | |||
| 4 | struct 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 | |||
| 39 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct 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 | |||
| 71 | private 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | struct 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 @@ | |||
| 1 | import SwiftUI | ||
| 2 | |||
| 3 | struct 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 |
| 4 | An iOS utility app for querying DNS records and inspecting SSL/TLS certificates | 4 | An iOS utility app for querying DNS records, inspecting SSL/TLS certificates, |
| 5 | for any domain. Designed for developers, sysadmins, and anyone who manages or | 5 | checking HTTP headers, measuring TCP reachability, geolocating IP addresses, |
| 6 | troubleshoots domains and servers. | 6 | analyzing email security records, tracing redirect chains, performing reverse |
| 7 | DNS lookups, and scanning common ports for any domain. Designed for developers, | ||
| 8 | sysadmins, and anyone who manages or troubleshoots domains and servers. | ||
| 7 | 9 | ||
| 8 | ## Target user | 10 | ## Target user |
| 9 | Technically literate adults — developers, IT/sysadmin, homelab enthusiasts. | 11 | Technically 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. | |||
| 35 | If a record type returns no results, show "No records found" for that type | 39 | If a record type returns no results, show "No records found" for that type |
| 36 | rather than hiding the section entirely. | 40 | rather than hiding the section entirely. |
| 37 | 41 | ||
| 42 | After the apex query, also queries `*.{domain}` for A, AAAA, MX, and TXT. | ||
| 43 | Wildcard results are shown as a sub-section beneath each record type, labelled | ||
| 44 | with `*.{domain}`. Hidden if no wildcard records are returned. | ||
| 45 | |||
| 38 | ### SSL/TLS Certificate Check | 46 | ### SSL/TLS Certificate Check |
| 39 | Connect to the domain on port 443 via URLSession and inspect the server's | 47 | Connect to the domain on port 443 via URLSession and inspect the server's |
| 40 | certificate chain using URLSession delegate methods. Display: | 48 | certificate 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 | ||
| 57 | Fire a HEAD request to `https://{domain}` and display all response headers. | ||
| 58 | Header names in cyan, values in primary. Security-relevant headers highlighted | ||
| 59 | in yellow: `strict-transport-security`, `x-frame-options`, | ||
| 60 | `x-content-type-options`, `content-security-policy`, `referrer-policy`. | ||
| 61 | Service: `HTTPHeadersService.swift`. | ||
| 62 | |||
| 63 | ### Reachability / TCP Latency | ||
| 64 | Use `NWConnection` (Network framework) to attempt TCP connections on ports 443 | ||
| 65 | and 80. Measure time to `.ready` state. Display green/red dot, latency in ms, | ||
| 66 | and reachable/unreachable status. Timeout after 5 seconds. | ||
| 67 | Service: `ReachabilityService.swift`. | ||
| 68 | |||
| 69 | ### IP Geolocation with Map | ||
| 70 | After DNS A record resolves, take the first IP and query | ||
| 71 | `https://ipapi.co/{ip}/json/` for country, region, city, org, lat/lon. | ||
| 72 | Display in an "IP Location" section with a SwiftUI `Map` (MapKit) centered on | ||
| 73 | the coordinates with a `Marker`. Map height 180pt, `.standard` style. | ||
| 74 | Service: `IPGeolocationService.swift`. | ||
| 75 | |||
| 76 | ### Email Security Analysis | ||
| 77 | Parse SPF from existing TXT records (no extra query). Query `_dmarc.{domain}` | ||
| 78 | for DMARC and try common DKIM selectors (`default`, `google`, `mail`) via DoH. | ||
| 79 | Display green checkmark if found, red ✗ if not. Full record values truncated | ||
| 80 | to 80 chars with tap-to-expand. Triggered after DNS completes. | ||
| 81 | Service: `EmailSecurityService.swift`. | ||
| 82 | |||
| 83 | ### Reverse DNS / PTR Lookup | ||
| 84 | After DNS A record resolves, construct reverse DNS name (reversed octets + | ||
| 85 | `.in-addr.arpa`) and query PTR record via Cloudflare DoH. Displayed inline | ||
| 86 | in the DNS Records section below the A record sub-section. | ||
| 87 | Service: `ReverseDNSService.swift`. | ||
| 88 | |||
| 89 | ### Redirect Chain | ||
| 90 | Fire HTTP request to `http://{domain}` with redirects disabled. Follow up to | ||
| 91 | 10 redirects manually, recording each hop's URL and status code. Display step | ||
| 92 | number, status code in cyan, URL, and "(final)" on the last hop. Shows | ||
| 93 | "No redirects — direct connection" if the first request returns 200. | ||
| 94 | Service: `RedirectChainService.swift`. | ||
| 95 | |||
| 96 | ### Common Port Scanner | ||
| 97 | Probe 10 common ports (21/FTP, 22/SSH, 25/SMTP, 80/HTTP, 443/HTTPS, | ||
| 98 | 587/SMTP-TLS, 3306/MySQL, 5432/PostgreSQL, 8080/HTTP-Alt, 8443/HTTPS-Alt) | ||
| 99 | using `NWConnection` with 3-second timeout. All probes run concurrently. | ||
| 100 | Green dot for open, grey dot for closed. Closed is expected — no error shown. | ||
| 101 | Service: `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 |
| 63 | Store the last 20 searched domains locally using UserDefaults. Display them as | 115 | Store the last 20 searched domains locally using UserDefaults. Display them as |
| @@ -65,15 +117,55 @@ a tappable list below the text field when no results are showing. Tapping a | |||
| 65 | recent domain populates the text field and runs the lookup immediately. Include | 117 | recent domain populates the text field and runs the lookup immediately. Include |
| 66 | a "Clear" button to wipe history. Most recent at the top. | 118 | a "Clear" button to wipe history. Most recent at the top. |
| 67 | 119 | ||
| 120 | ### Saved domains | ||
| 121 | Bookmark button (SF Symbol: `bookmark` / `bookmark.fill`) in the results | ||
| 122 | toolbar area, next to the share button. Tapping saves/unsaves the current | ||
| 123 | domain. Filled icon when saved. Saved domains viewable from a toolbar button | ||
| 124 | that pushes to `SavedDomainsView` — a list of saved domains, tappable to run | ||
| 125 | lookups, with swipe-to-delete and an Edit button for bulk deletion. | ||
| 126 | Persisted in UserDefaults under key `savedDomains`. | ||
| 127 | |||
| 128 | ### Lookup history with cached results | ||
| 129 | After each successful lookup, a snapshot of all results (DNS, SSL, HTTP headers, | ||
| 130 | reachability, geolocation, email security, PTR, redirect chain, port scan) is | ||
| 131 | saved to history in UserDefaults as JSON. Capped at 50 entries. `HistoryView` | ||
| 132 | shows past lookups with domain and timestamp. Tapping shows full cached results | ||
| 133 | in `HistoryDetailView` using the same layout, labelled as cached with the | ||
| 134 | original timestamp. Model: `HistoryEntry` (Codable). | ||
| 135 | |||
| 68 | ### Share / export | 136 | ### Share / export |
| 69 | A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the | 137 | A share button (SF Symbol: `square.and.arrow.up`) in the top-right of the |
| 70 | results area. Formats the full DNS and SSL results as plain text and presents | 138 | results area. Formats the full results as plain text and presents the iOS share |
| 71 | the iOS share sheet via ShareLink or UIActivityViewController. The export | 139 | sheet via UIActivityViewController. The export includes: domain, timestamp, |
| 72 | should include the domain, timestamp, all DNS records with TTLs, and all SSL | 140 | reachability, redirect chain, all DNS records with TTLs and PTR, email security |
| 73 | cert fields. | 141 | records, SSL cert fields, HTTP headers, IP geolocation data, and port scan |
| 142 | results. | ||
| 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 |
| 76 | A developer pastes a domain, taps run, and within a couple of seconds sees a | 165 | A developer pastes a domain, taps run, and within a couple of seconds sees a |
| 77 | clean breakdown of every DNS record type and the full SSL cert status. It should | 166 | clean breakdown of TCP reachability, redirect chain, every DNS record type with |
| 78 | feel like a native, polished version of running `dig` and `openssl s_client` | 167 | reverse DNS, email security posture, the full SSL cert status, HTTP response |
| 79 | from the terminal. | 168 | headers with security headers highlighted, IP geolocation with a map, and a |
| 169 | port scan of common services. It should feel like a native, polished version of | ||
| 170 | running `dig`, `openssl s_client`, `curl -I`, `nmap`, and `whois` from the | ||
| 171 | terminal. | ||