Commit 376e7b55b4
Verified · cmc
Layout: unified · split
DomainDig.xcodeproj/project.pbxproj +19 −4
| @@ -10,9 +10,22 @@ | ||
| 10 | 10 | 8B7800692F6090E300933221 /* DomainDig.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = DomainDig.app; sourceTree = BUILT_PRODUCTS_DIR; }; |
| 11 | 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 | 23 | /* Begin PBXFileSystemSynchronizedRootGroup section */ |
| 14 | 24 | 8B78006B2F6090E300933221 /* DomainDig */ = { |
| 15 | 25 | isa = PBXFileSystemSynchronizedRootGroup; |
| 26 | exceptions = ( | |
| 27 | 8B1B506D2F666F64005C246F /* Exceptions for "DomainDig" folder in "DomainDig" target */, | |
| 28 | ); | |
| 16 | 29 | path = DomainDig; |
| 17 | 30 | sourceTree = "<group>"; |
| 18 | 31 | }; |
| @@ -252,10 +265,11 @@ | ||
| 252 | 265 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 253 | 266 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 254 | 267 | CODE_SIGN_STYLE = Automatic; |
| 255 | CURRENT_PROJECT_VERSION = 1; | |
| 268 | CURRENT_PROJECT_VERSION = 3; | |
| 256 | 269 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 257 | 270 | ENABLE_PREVIEWS = YES; |
| 258 | 271 | GENERATE_INFOPLIST_FILE = YES; |
| 272 | INFOPLIST_FILE = DomainDig/Info.plist; | |
| 259 | 273 | INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig"; |
| 260 | 274 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; |
| 261 | 275 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
| @@ -268,7 +282,7 @@ | ||
| 268 | 282 | "$(inherited)", |
| 269 | 283 | "@executable_path/Frameworks", |
| 270 | 284 | ); |
| 271 | MARKETING_VERSION = 1.0; | |
| 285 | MARKETING_VERSION = 1.2; | |
| 272 | 286 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 273 | 287 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 274 | 288 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -287,10 +301,11 @@ | ||
| 287 | 301 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; |
| 288 | 302 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 289 | 303 | CODE_SIGN_STYLE = Automatic; |
| 290 | CURRENT_PROJECT_VERSION = 1; | |
| 304 | CURRENT_PROJECT_VERSION = 3; | |
| 291 | 305 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 292 | 306 | ENABLE_PREVIEWS = YES; |
| 293 | 307 | GENERATE_INFOPLIST_FILE = YES; |
| 308 | INFOPLIST_FILE = DomainDig/Info.plist; | |
| 294 | 309 | INFOPLIST_KEY_CFBundleDisplayName = "Domain Dig"; |
| 295 | 310 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities"; |
| 296 | 311 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
| @@ -303,7 +318,7 @@ | ||
| 303 | 318 | "$(inherited)", |
| 304 | 319 | "@executable_path/Frameworks", |
| 305 | 320 | ); |
| 306 | MARKETING_VERSION = 1.0; | |
| 321 | MARKETING_VERSION = 1.2; | |
| 307 | 322 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.DomainDig; |
| 308 | 323 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 309 | 324 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
DomainDig/ContentView.swift +383 −19
| @@ -1,4 +1,5 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | import MapKit | |
| 2 | 3 | |
| 3 | 4 | struct ContentView: View { |
| 4 | 5 | @State private var viewModel = DomainViewModel() |
| @@ -10,21 +11,15 @@ struct ContentView: View { | ||
| 10 | 11 | VStack(spacing: 0) { |
| 11 | 12 | inputSection |
| 12 | 13 | if viewModel.hasRun { |
| 13 | if viewModel.resultsLoaded { | |
| 14 | HStack { | |
| 15 | Spacer() | |
| 16 | Button { | |
| 17 | shareResults() | |
| 18 | } label: { | |
| 19 | Image(systemName: "square.and.arrow.up") | |
| 20 | .font(.system(.body)) | |
| 21 | .foregroundStyle(.secondary) | |
| 22 | } | |
| 23 | } | |
| 24 | .padding(.top, 8) | |
| 25 | } | |
| 14 | actionButtons | |
| 15 | reachabilitySection | |
| 16 | redirectChainSection | |
| 26 | 17 | dnsResultsSection |
| 18 | emailSecuritySection | |
| 27 | 19 | sslResultsSection |
| 20 | httpHeadersSection | |
| 21 | ipGeolocationSection | |
| 22 | portScanSection | |
| 28 | 23 | } else if !viewModel.recentSearches.isEmpty { |
| 29 | 24 | recentSearchesSection |
| 30 | 25 | } |
| @@ -36,6 +31,30 @@ struct ContentView: View { | ||
| 36 | 31 | .navigationTitle("DomainDig") |
| 37 | 32 | .toolbarColorScheme(.dark, for: .navigationBar) |
| 38 | 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 | 59 | .onAppear { |
| 41 | 60 | domainFieldFocused = true |
| @@ -72,6 +91,31 @@ struct ContentView: View { | ||
| 72 | 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 | 119 | // MARK: - Recent Searches |
| 76 | 120 | |
| 77 | 121 | private var recentSearchesSection: some View { |
| @@ -108,6 +152,110 @@ struct ContentView: View { | ||
| 108 | 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 | 259 | // MARK: - DNS Results |
| 112 | 260 | |
| 113 | 261 | private var dnsResultsSection: some View { |
| @@ -123,10 +271,40 @@ struct ContentView: View { | ||
| 123 | 271 | } else { |
| 124 | 272 | ForEach(viewModel.dnsSections) { section in |
| 125 | 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 | 310 | private func dnsRecordSection(_ section: DNSSection) -> some View { |
| @@ -146,7 +324,6 @@ struct ContentView: View { | ||
| 146 | 324 | dnsRecordRows(section.records) |
| 147 | 325 | } |
| 148 | 326 | |
| 149 | // Wildcard sub-section (only shown when records exist) | |
| 150 | 327 | if !section.wildcardRecords.isEmpty { |
| 151 | 328 | Text("*.\(viewModel.searchedDomain)") |
| 152 | 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 | 417 | // MARK: - SSL Results |
| 181 | 418 | |
| 182 | 419 | private var sslResultsSection: some View { |
| @@ -201,7 +438,6 @@ struct ContentView: View { | ||
| 201 | 438 | certRow("Common Name", info.commonName) |
| 202 | 439 | certRow("Issuer", info.issuer) |
| 203 | 440 | |
| 204 | // SANs | |
| 205 | 441 | VStack(alignment: .leading, spacing: 2) { |
| 206 | 442 | Text("SANs") |
| 207 | 443 | .font(.system(.caption2, design: .monospaced)) |
| @@ -235,6 +471,136 @@ struct ContentView: View { | ||
| 235 | 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 | 604 | // MARK: - Helpers |
| 239 | 605 | |
| 240 | 606 | private func sectionHeader(_ title: String) -> some View { |
| @@ -269,8 +635,6 @@ struct ContentView: View { | ||
| 269 | 635 | |
| 270 | 636 | private func shareResults() { |
| 271 | 637 | let text = viewModel.exportText() |
| 272 | ||
| 273 | // Write to a named temp file so "Save to Files" uses a proper filename | |
| 274 | 638 | let dateFmt = DateFormatter() |
| 275 | 639 | dateFmt.dateFormat = "yyyyMMdd_HHmmss" |
| 276 | 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 | 663 | static let certDate: DateFormatter = { |
| 300 | 664 | let f = DateFormatter() |
| 301 | 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 | 38 | static func lookupAll(domain: String) async -> [DNSSection] { |
| 42 | 39 | // Each task returns (recordType, apex records, wildcard records). |
| 43 | 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 | 45 | return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in |
| 46 | 46 | for recordType in DNSRecordType.allCases { |
| 47 | let shouldQueryWildcard = wildcardTypes.contains(recordType) | |
| 47 | 48 | group.addTask { |
| 48 | 49 | var apexRecords: [DNSRecord] = [] |
| 49 | 50 | var wildcardRecords: [DNSRecord] = [] |
| @@ -57,7 +58,7 @@ struct DNSLookupService { | ||
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 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 | 62 | do { |
| 62 | 63 | wildcardRecords = try await lookup(domain: "*.\(domain)", recordType: recordType) |
| 63 | 64 | } catch { |
DomainDig/DomainViewModel.swift +418 −10
| @@ -6,22 +6,135 @@ import SwiftUI | ||
| 6 | 6 | final class DomainViewModel { |
| 7 | 7 | var domain: String = "" |
| 8 | 8 | |
| 9 | // DNS | |
| 9 | 10 | var dnsSections: [DNSSection] = [] |
| 10 | 11 | var dnsLoading = false |
| 11 | 12 | var dnsError: String? |
| 12 | 13 | |
| 14 | // SSL | |
| 13 | 15 | var sslInfo: SSLCertificateInfo? |
| 14 | 16 | var sslLoading = false |
| 15 | 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 | 54 | var hasRun = false |
| 18 | 55 | private(set) var searchedDomain: String = "" |
| 19 | 56 | |
| 57 | // MARK: - Recent Searches | |
| 58 | ||
| 20 | 59 | private static let recentSearchesKey = "recentSearches" |
| 21 | 60 | private static let maxRecent = 20 |
| 22 | 61 | |
| 23 | 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 | 138 | var trimmedDomain: String { |
| 26 | 139 | domain |
| 27 | 140 | .trimmingCharacters(in: .whitespacesAndNewlines) |
| @@ -30,6 +143,49 @@ final class DomainViewModel { | ||
| 30 | 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 | 189 | func run() { |
| 34 | 190 | let target = trimmedDomain |
| 35 | 191 | guard !target.isEmpty else { return } |
| @@ -37,25 +193,77 @@ final class DomainViewModel { | ||
| 37 | 193 | addRecentSearch(target) |
| 38 | 194 | searchedDomain = target |
| 39 | 195 | hasRun = true |
| 196 | ||
| 197 | // Reset all state | |
| 40 | 198 | dnsSections = [] |
| 41 | 199 | dnsError = nil |
| 42 | 200 | dnsLoading = true |
| 43 | 201 | sslInfo = nil |
| 44 | 202 | sslError = nil |
| 45 | 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 | 226 | Task { |
| 48 | 227 | await withTaskGroup(of: Void.self) { group in |
| 228 | // DNS → chained: email security, PTR, geolocation | |
| 49 | 229 | group.addTask { @MainActor in |
| 50 | 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 | 244 | group.addTask { @MainActor in |
| 53 | 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 | 267 | private func runDNS(domain: String) async { |
| 60 | 268 | do { |
| 61 | 269 | let sections = await DNSLookupService.lookupAll(domain: domain) |
| @@ -74,27 +282,157 @@ final class DomainViewModel { | ||
| 74 | 282 | sslLoading = false |
| 75 | 283 | } |
| 76 | 284 | |
| 77 | /// True when both lookups have finished (regardless of success/failure). | |
| 78 | var resultsLoaded: Bool { | |
| 79 | hasRun && !dnsLoading && !sslLoading | |
| 285 | private func runHTTPHeaders(domain: String) async { | |
| 286 | do { | |
| 287 | let headers = try await HTTPHeadersService.fetch(domain: domain) | |
| 288 | httpHeaders = headers | |
| 289 | } catch { | |
| 290 | httpHeadersError = error.localizedDescription | |
| 291 | } | |
| 292 | httpHeadersLoading = false | |
| 293 | } | |
| 294 | ||
| 295 | private func runReachability(domain: String) async { | |
| 296 | let results = await ReachabilityService.checkAll(domain: domain) | |
| 297 | reachabilityResults = results | |
| 298 | reachabilityLoading = false | |
| 299 | } | |
| 300 | ||
| 301 | private func runIPGeolocation() async { | |
| 302 | // Find the first A record IP | |
| 303 | guard let aSection = dnsSections.first(where: { $0.recordType == .A }), | |
| 304 | let firstIP = aSection.records.first?.value else { | |
| 305 | ipGeolocationError = "No A record available" | |
| 306 | ipGeolocationLoading = false | |
| 307 | return | |
| 308 | } | |
| 309 | do { | |
| 310 | let geo = try await IPGeolocationService.lookup(ip: firstIP) | |
| 311 | ipGeolocation = geo | |
| 312 | } catch { | |
| 313 | ipGeolocationError = error.localizedDescription | |
| 314 | } | |
| 315 | ipGeolocationLoading = false | |
| 316 | } | |
| 317 | ||
| 318 | private func runEmailSecurity(domain: String) async { | |
| 319 | // Extract TXT records from already-fetched DNS sections | |
| 320 | let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] | |
| 321 | let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) | |
| 322 | emailSecurity = result | |
| 323 | emailSecurityLoading = false | |
| 324 | } | |
| 325 | ||
| 326 | private func runReverseDNS() async { | |
| 327 | guard let aSection = dnsSections.first(where: { $0.recordType == .A }), | |
| 328 | let firstIP = aSection.records.first?.value else { | |
| 329 | ptrError = "No A record available" | |
| 330 | ptrLoading = false | |
| 331 | return | |
| 332 | } | |
| 333 | let result = await ReverseDNSService.lookup(ip: firstIP) | |
| 334 | ptrRecord = result | |
| 335 | if result == nil { | |
| 336 | ptrError = "No PTR record found" | |
| 337 | } | |
| 338 | ptrLoading = false | |
| 339 | } | |
| 340 | ||
| 341 | private func runRedirectChain(domain: String) async { | |
| 342 | do { | |
| 343 | let hops = try await RedirectChainService.trace(domain: domain) | |
| 344 | redirectChain = hops | |
| 345 | } catch { | |
| 346 | redirectChainError = error.localizedDescription | |
| 347 | } | |
| 348 | redirectChainLoading = false | |
| 349 | } | |
| 350 | ||
| 351 | private func runPortScan(domain: String) async { | |
| 352 | let results = await PortScanService.scanAll(domain: domain) | |
| 353 | portScanResults = results | |
| 354 | portScanLoading = false | |
| 80 | 355 | } |
| 81 | 356 | |
| 82 | 357 | // MARK: - Export |
| 83 | 358 | |
| 84 | 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 | 394 | let dateFmt = DateFormatter() |
| 86 | 395 | dateFmt.dateFormat = "yyyy-MM-dd HH:mm" |
| 87 | let now = dateFmt.string(from: Date()) | |
| 88 | 396 | |
| 89 | 397 | var lines: [String] = [ |
| 90 | 398 | "DomainDig Export", |
| 91 | "Domain: \(searchedDomain)", | |
| 92 | "Date: \(now)", | |
| 93 | "", | |
| 94 | "DNS Records", | |
| 95 | "-----------" | |
| 399 | "Domain: \(domain)", | |
| 400 | "Date: \(dateFmt.string(from: date))", | |
| 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 | 436 | for section in dnsSections { |
| 99 | 437 | lines.append(section.recordType.rawValue) |
| 100 | 438 | if let error = section.error { |
| @@ -107,13 +445,30 @@ final class DomainViewModel { | ||
| 107 | 445 | } |
| 108 | 446 | } |
| 109 | 447 | if !section.wildcardRecords.isEmpty { |
| 110 | lines.append("*.\(searchedDomain)") | |
| 448 | lines.append("*.\(domain)") | |
| 111 | 449 | for record in section.wildcardRecords { |
| 112 | 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 | 472 | if let info = sslInfo { |
| 118 | 473 | let certDateFmt = DateFormatter() |
| 119 | 474 | certDateFmt.dateStyle = .medium |
| @@ -136,6 +491,59 @@ final class DomainViewModel { | ||
| 136 | 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 | 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 | 3 | // MARK: - DNS Models |
| 4 | 4 | |
| 5 | enum DNSRecordType: String, CaseIterable { | |
| 5 | enum DNSRecordType: String, CaseIterable, Codable { | |
| 6 | 6 | case A |
| 7 | 7 | case AAAA |
| 8 | 8 | case MX |
| @@ -22,14 +22,14 @@ enum DNSRecordType: String, CaseIterable { | ||
| 22 | 22 | } |
| 23 | 23 | } |
| 24 | 24 | |
| 25 | struct DNSRecord: Identifiable { | |
| 26 | let id = UUID() | |
| 25 | struct DNSRecord: Identifiable, Codable { | |
| 26 | var id = UUID() | |
| 27 | 27 | let value: String |
| 28 | 28 | let ttl: Int |
| 29 | 29 | } |
| 30 | 30 | |
| 31 | struct DNSSection: Identifiable { | |
| 32 | let id = UUID() | |
| 31 | struct DNSSection: Identifiable, Codable { | |
| 32 | var id = UUID() | |
| 33 | 33 | let recordType: DNSRecordType |
| 34 | 34 | var records: [DNSRecord] |
| 35 | 35 | var wildcardRecords: [DNSRecord] = [] |
| @@ -38,7 +38,7 @@ struct DNSSection: Identifiable { | ||
| 38 | 38 | |
| 39 | 39 | // MARK: - SSL Models |
| 40 | 40 | |
| 41 | struct SSLCertificateInfo { | |
| 41 | struct SSLCertificateInfo: Codable { | |
| 42 | 42 | let commonName: String |
| 43 | 43 | let subjectAltNames: [String] |
| 44 | 44 | let issuer: String |
| @@ -48,6 +48,130 @@ struct SSLCertificateInfo { | ||
| 48 | 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 | 175 | // MARK: - Cloudflare DNS-over-HTTPS Response |
| 52 | 176 | |
| 53 | 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 | 1 | # DomainDig – Project Context |
| 2 | 2 | |
| 3 | 3 | ## What this is |
| 4 | An iOS utility app for querying DNS records and inspecting SSL/TLS certificates | |
| 5 | for any domain. Designed for developers, sysadmins, and anyone who manages or | |
| 6 | troubleshoots domains and servers. | |
| 4 | An iOS utility app for querying DNS records, inspecting SSL/TLS certificates, | |
| 5 | checking HTTP headers, measuring TCP reachability, geolocating IP addresses, | |
| 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 | 10 | ## Target user |
| 9 | 11 | Technically literate adults — developers, IT/sysadmin, homelab enthusiasts. |
| @@ -18,6 +20,8 @@ clean and information-dense, not hand-holding. | ||
| 18 | 20 | - No onboarding, no tutorials, no splash screens |
| 19 | 21 | - Fast — results should appear as soon as they're available, not after all |
| 20 | 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 | 26 | ## Features |
| 23 | 27 | |
| @@ -35,6 +39,10 @@ Display each record type in its own section. Show TTL alongside each result. | ||
| 35 | 39 | If a record type returns no results, show "No records found" for that type |
| 36 | 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 | 46 | ### SSL/TLS Certificate Check |
| 39 | 47 | Connect to the domain on port 443 via URLSession and inspect the server's |
| 40 | 48 | certificate chain using URLSession delegate methods. Display: |
| @@ -45,19 +53,63 @@ certificate chain using URLSession delegate methods. Display: | ||
| 45 | 53 | - Days until expiry — highlight in red if under 30 days, yellow if under 60 |
| 46 | 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 | 103 | ### Results layout |
| 49 | 104 | - Domain input at the top — large text field, keyboard shows on launch |
| 50 | - Run button to trigger both DNS and SSL lookups simultaneously | |
| 51 | - DNS and SSL results displayed in clearly separated sections below | |
| 52 | - Each section loads independently — don't block SSL results waiting for DNS | |
| 53 | or vice versa | |
| 54 | ||
| 55 | ## Technical constraints | |
| 56 | - SwiftUI, iOS only | |
| 57 | - Fully offline except for DNS-over-HTTPS requests and SSL connections | |
| 58 | - No accounts, no analytics, no ads | |
| 59 | - No third-party dependencies — URLSession and Network framework only | |
| 60 | - Targets latest iOS | |
| 105 | - Run button to trigger all lookups simultaneously | |
| 106 | - Results displayed in clearly separated sections: | |
| 107 | Reachability → Redirect Chain → DNS Records (with PTR inline) → | |
| 108 | Email Security → SSL/TLS Certificate → HTTP Headers → IP Location → | |
| 109 | Open Ports | |
| 110 | - Each section loads independently — don't block one section waiting for another | |
| 111 | - Email security, PTR, and IP geolocation are chained after DNS; all others | |
| 112 | run in parallel | |
| 61 | 113 | |
| 62 | 114 | ### Recent searches |
| 63 | 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 | 117 | recent domain populates the text field and runs the lookup immediately. Include |
| 66 | 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 | 136 | ### Share / export |
| 69 | 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 | |
| 71 | the iOS share sheet via ShareLink or UIActivityViewController. The export | |
| 72 | should include the domain, timestamp, all DNS records with TTLs, and all SSL | |
| 73 | cert fields. | |
| 138 | results area. Formats the full results as plain text and presents the iOS share | |
| 139 | sheet via UIActivityViewController. The export includes: domain, timestamp, | |
| 140 | reachability, redirect chain, all DNS records with TTLs and PTR, email security | |
| 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 | 164 | ## What good looks like |
| 76 | 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 | |
| 78 | feel like a native, polished version of running `dig` and `openssl s_client` | |
| 79 | from the terminal. | |
| 166 | clean breakdown of TCP reachability, redirect chain, every DNS record type with | |
| 167 | reverse DNS, email security posture, the full SSL cert status, HTTP response | |
| 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. | |