Commit ee2520f2c0
Verified · cmc
Layout: unified · split
DomainDig/ContentView.swift +520 −664
| @@ -1,5 +1,5 @@ | ||
| 1 | import SwiftUI | |
| 2 | 1 | import MapKit |
| 2 | import SwiftUI | |
| 3 | 3 | |
| 4 | 4 | struct ContentView: View { |
| 5 | 5 | @State private var viewModel = DomainViewModel() |
| @@ -14,14 +14,59 @@ struct ContentView: View { | ||
| 14 | 14 | inputSection |
| 15 | 15 | if viewModel.hasRun { |
| 16 | 16 | actionButtons |
| 17 | reachabilitySection | |
| 18 | redirectChainSection | |
| 19 | dnsResultsSection | |
| 20 | emailSecuritySection | |
| 21 | sslResultsSection | |
| 22 | httpHeadersSection | |
| 23 | ipGeolocationSection | |
| 24 | portScanSection | |
| 17 | SummaryView(fields: viewModel.summaryFields) | |
| 18 | .padding(.top, 8) | |
| 19 | DomainSectionView(rows: viewModel.domainRows) | |
| 20 | .padding(.top, 16) | |
| 21 | DNSSectionView( | |
| 22 | dnssecLabel: viewModel.dnssecLabel, | |
| 23 | sections: viewModel.dnsRows, | |
| 24 | ptrMessage: viewModel.ptrMessage, | |
| 25 | loading: viewModel.dnsLoading || viewModel.ptrLoading, | |
| 26 | sectionError: viewModel.dnsError | |
| 27 | ) | |
| 28 | .padding(.top, 16) | |
| 29 | WebSectionView( | |
| 30 | certificateRows: viewModel.webCertificateRows, | |
| 31 | sslInfo: viewModel.sslInfo, | |
| 32 | sslLoading: viewModel.sslLoading || viewModel.hstsLoading, | |
| 33 | sslError: viewModel.sslError, | |
| 34 | responseRows: viewModel.webResponseRows, | |
| 35 | headers: viewModel.httpHeaders, | |
| 36 | headersLoading: viewModel.httpHeadersLoading, | |
| 37 | headersError: viewModel.httpHeadersError, | |
| 38 | redirects: viewModel.redirectRows, | |
| 39 | redirectLoading: viewModel.redirectChainLoading, | |
| 40 | redirectError: viewModel.redirectChainError, | |
| 41 | finalURL: viewModel.currentSnapshot.redirectChain.last?.url | |
| 42 | ) | |
| 43 | .padding(.top, 16) | |
| 44 | EmailSectionView( | |
| 45 | rows: viewModel.emailRows, | |
| 46 | loading: viewModel.emailSecurityLoading, | |
| 47 | error: viewModel.emailSecurityError | |
| 48 | ) | |
| 49 | .padding(.top, 16) | |
| 50 | NetworkSectionView( | |
| 51 | reachabilityRows: viewModel.reachabilityRows, | |
| 52 | reachabilityLoading: viewModel.reachabilityLoading, | |
| 53 | reachabilityError: viewModel.reachabilityError, | |
| 54 | locationRows: viewModel.locationRows, | |
| 55 | geolocation: viewModel.ipGeolocation, | |
| 56 | geolocationLoading: viewModel.ipGeolocationLoading, | |
| 57 | geolocationError: viewModel.ipGeolocationError, | |
| 58 | standardPortRows: viewModel.standardPortRows, | |
| 59 | customPortRows: viewModel.customPortRows, | |
| 60 | portScanLoading: viewModel.portScanLoading, | |
| 61 | portScanError: viewModel.portScanError, | |
| 62 | customPortScanLoading: viewModel.customPortScanLoading, | |
| 63 | customPortScanError: viewModel.customPortScanError, | |
| 64 | isCloudflareProxied: viewModel.isCloudflareProxied, | |
| 65 | customPortsExpanded: $customPortsExpanded, | |
| 66 | customPortInput: $customPortInput, | |
| 67 | onScanCustomPorts: runCustomPortScan | |
| 68 | ) | |
| 69 | .padding(.top, 16) | |
| 25 | 70 | } else if !viewModel.recentSearches.isEmpty { |
| 26 | 71 | recentSearchesSection |
| 27 | 72 | } |
| @@ -55,8 +100,6 @@ struct ContentView: View { | ||
| 55 | 100 | Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") |
| 56 | 101 | .foregroundStyle(.secondary) |
| 57 | 102 | } |
| 58 | } | |
| 59 | ToolbarItem(placement: .topBarTrailing) { | |
| 60 | 103 | NavigationLink { |
| 61 | 104 | SettingsView() |
| 62 | 105 | } label: { |
| @@ -71,8 +114,6 @@ struct ContentView: View { | ||
| 71 | 114 | } |
| 72 | 115 | } |
| 73 | 116 | |
| 74 | // MARK: - Input | |
| 75 | ||
| 76 | 117 | private var inputSection: some View { |
| 77 | 118 | VStack(spacing: 12) { |
| 78 | 119 | TextField("e.g. cleberg.net", text: $viewModel.domain) |
| @@ -101,8 +142,6 @@ struct ContentView: View { | ||
| 101 | 142 | .padding(.vertical, 16) |
| 102 | 143 | } |
| 103 | 144 | |
| 104 | // MARK: - Action Buttons (Share + Bookmark) | |
| 105 | ||
| 106 | 145 | private var actionButtons: some View { |
| 107 | 146 | HStack { |
| 108 | 147 | Spacer() |
| @@ -123,11 +162,8 @@ struct ContentView: View { | ||
| 123 | 162 | } |
| 124 | 163 | } |
| 125 | 164 | } |
| 126 | .padding(.top, 8) | |
| 127 | 165 | } |
| 128 | 166 | |
| 129 | // MARK: - Recent Searches | |
| 130 | ||
| 131 | 167 | private var recentSearchesSection: some View { |
| 132 | 168 | VStack(alignment: .leading, spacing: 8) { |
| 133 | 169 | HStack { |
| @@ -162,778 +198,605 @@ struct ContentView: View { | ||
| 162 | 198 | .padding(.top, 8) |
| 163 | 199 | } |
| 164 | 200 | |
| 165 | // MARK: - Reachability | |
| 166 | ||
| 167 | private var reachabilitySection: some View { | |
| 168 | VStack(alignment: .leading, spacing: 12) { | |
| 169 | sectionHeader("Reachability") | |
| 170 | ||
| 171 | if viewModel.reachabilityLoading { | |
| 172 | ProgressView("Checking ports…") | |
| 173 | .frame(maxWidth: .infinity, alignment: .center) | |
| 174 | .padding() | |
| 175 | } else if let error = viewModel.reachabilityError { | |
| 176 | errorLabel(error) | |
| 177 | } else { | |
| 178 | VStack(alignment: .leading, spacing: 4) { | |
| 179 | ForEach(viewModel.reachabilityResults) { result in | |
| 180 | HStack(spacing: 8) { | |
| 181 | Circle() | |
| 182 | .fill(result.reachable ? Color.green : Color.red) | |
| 183 | .frame(width: 8, height: 8) | |
| 184 | Text("Port \(result.port)") | |
| 185 | .font(.system(.caption, design: .monospaced)) | |
| 186 | if result.reachable, let ms = result.latencyMs { | |
| 187 | Text("\(ms)ms") | |
| 188 | .font(.system(.caption, design: .monospaced)) | |
| 189 | .foregroundStyle(.secondary) | |
| 190 | } else if !result.reachable { | |
| 191 | Text("—") | |
| 192 | .font(.system(.caption, design: .monospaced)) | |
| 193 | .foregroundStyle(.secondary) | |
| 194 | } | |
| 195 | Spacer() | |
| 196 | Text(result.reachable ? "Reachable" : "Unreachable") | |
| 197 | .font(.system(.caption, design: .monospaced)) | |
| 198 | .foregroundStyle(result.reachable ? .green : .red) | |
| 199 | } | |
| 200 | } | |
| 201 | } | |
| 202 | .padding(10) | |
| 203 | .background(Color(.systemGray6).opacity(0.5)) | |
| 204 | .cornerRadius(6) | |
| 205 | } | |
| 201 | private func runCustomPortScan() { | |
| 202 | let ports = parsedCustomPorts(from: customPortInput) | |
| 203 | Task { | |
| 204 | await viewModel.runCustomPortScan(ports: ports) | |
| 206 | 205 | } |
| 207 | .padding(.top, 8) | |
| 208 | 206 | } |
| 209 | 207 | |
| 210 | // MARK: - Redirect Chain | |
| 211 | ||
| 212 | private var redirectChainSection: some View { | |
| 213 | VStack(alignment: .leading, spacing: 12) { | |
| 214 | sectionHeader("Redirect Chain") | |
| 215 | ||
| 216 | if viewModel.redirectChainLoading { | |
| 217 | ProgressView("Tracing redirects…") | |
| 218 | .frame(maxWidth: .infinity, alignment: .center) | |
| 219 | .padding() | |
| 220 | } else if let error = viewModel.redirectChainError { | |
| 221 | errorLabel(error) | |
| 222 | } else if viewModel.redirectChain.isEmpty { | |
| 223 | Text("No redirect data") | |
| 224 | .font(.system(.caption, design: .monospaced)) | |
| 225 | .foregroundStyle(.secondary) | |
| 226 | .padding(8) | |
| 227 | } else if viewModel.redirectChain.count == 1, | |
| 228 | let only = viewModel.redirectChain.first, | |
| 229 | only.isFinal, !(300...399).contains(only.statusCode) { | |
| 230 | Text("No redirects — direct connection") | |
| 231 | .font(.system(.caption, design: .monospaced)) | |
| 232 | .foregroundStyle(.secondary) | |
| 233 | .padding(10) | |
| 234 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 235 | .background(Color(.systemGray6).opacity(0.5)) | |
| 236 | .cornerRadius(6) | |
| 237 | } else { | |
| 238 | horizontallyScrollableCard { | |
| 239 | ForEach(viewModel.redirectChain) { hop in | |
| 240 | HStack(alignment: .top, spacing: 6) { | |
| 241 | Text("\(hop.stepNumber)") | |
| 242 | .font(.system(.caption, design: .monospaced)) | |
| 243 | .foregroundStyle(.secondary) | |
| 244 | .frame(width: 16, alignment: .trailing) | |
| 245 | Text("\(hop.statusCode)") | |
| 246 | .font(.system(.caption, design: .monospaced)) | |
| 247 | .foregroundStyle(.cyan) | |
| 248 | .frame(width: 30, alignment: .leading) | |
| 249 | Text(hop.url) | |
| 250 | .font(.system(.caption, design: .monospaced)) | |
| 251 | .foregroundStyle(.primary) | |
| 252 | .textSelection(.enabled) | |
| 253 | if hop.isFinal { | |
| 254 | Text("(final)") | |
| 255 | .font(.system(.caption2, design: .monospaced)) | |
| 256 | .foregroundStyle(.secondary) | |
| 257 | } | |
| 258 | } | |
| 259 | } | |
| 260 | } | |
| 261 | } | |
| 262 | } | |
| 263 | .padding(.top, 16) | |
| 264 | } | |
| 265 | ||
| 266 | // MARK: - DNS Results | |
| 208 | private func parsedCustomPorts(from input: String) -> [UInt16] { | |
| 209 | let parts = input.split(separator: ",", omittingEmptySubsequences: true) | |
| 210 | var seen = Set<UInt16>() | |
| 211 | var ports: [UInt16] = [] | |
| 267 | 212 | |
| 268 | private var dnsResultsSection: some View { | |
| 269 | VStack(alignment: .leading, spacing: 12) { | |
| 270 | HStack(alignment: .top, spacing: 8) { | |
| 271 | sectionHeader("DNS Records") | |
| 272 | Spacer() | |
| 273 | if let dnssecSigned = dnssecStatus { | |
| 274 | Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗") | |
| 275 | .font(.system(.caption2, design: .monospaced)) | |
| 276 | .foregroundStyle(dnssecSigned ? .green : .red) | |
| 277 | .padding(.top, 1) | |
| 278 | } | |
| 213 | for part in parts { | |
| 214 | let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 215 | guard let value = UInt16(trimmed), seen.insert(value).inserted else { | |
| 216 | continue | |
| 279 | 217 | } |
| 280 | ||
| 281 | if viewModel.dnsLoading { | |
| 282 | ProgressView("Querying DNS…") | |
| 283 | .frame(maxWidth: .infinity, alignment: .center) | |
| 284 | .padding() | |
| 285 | } else if let error = viewModel.dnsError { | |
| 286 | errorLabel(error) | |
| 287 | } else { | |
| 288 | ForEach(viewModel.dnsSections) { section in | |
| 289 | dnsRecordSection(section) | |
| 290 | if section.recordType == .A { | |
| 291 | ptrRow | |
| 292 | } | |
| 293 | } | |
| 218 | ports.append(value) | |
| 219 | if ports.count == 20 { | |
| 220 | break | |
| 294 | 221 | } |
| 295 | 222 | } |
| 296 | .padding(.top, 16) | |
| 297 | } | |
| 298 | 223 | |
| 299 | private var ptrRow: some View { | |
| 300 | horizontallyScrollableCard { | |
| 301 | Text("PTR (Reverse DNS)") | |
| 302 | .font(.system(.subheadline, design: .monospaced)) | |
| 303 | .fontWeight(.semibold) | |
| 304 | .foregroundStyle(.cyan) | |
| 305 | ||
| 306 | if viewModel.ptrLoading { | |
| 307 | ProgressView() | |
| 308 | .frame(maxWidth: .infinity, alignment: .center) | |
| 309 | .padding(4) | |
| 310 | } else if let ptr = viewModel.ptrRecord { | |
| 311 | Text(ptr) | |
| 312 | .font(.system(.caption, design: .monospaced)) | |
| 313 | .foregroundStyle(.primary) | |
| 314 | .textSelection(.enabled) | |
| 315 | } else { | |
| 316 | Text("No PTR record found") | |
| 317 | .font(.system(.caption, design: .monospaced)) | |
| 318 | .foregroundStyle(.secondary) | |
| 319 | } | |
| 320 | } | |
| 224 | return ports | |
| 321 | 225 | } |
| 322 | 226 | |
| 323 | private func dnsRecordSection(_ section: DNSSection) -> some View { | |
| 324 | horizontallyScrollableCard { | |
| 325 | Text(section.recordType.rawValue) | |
| 326 | .font(.system(.subheadline, design: .monospaced)) | |
| 327 | .fontWeight(.semibold) | |
| 328 | .foregroundStyle(.cyan) | |
| 329 | ||
| 330 | if let error = section.error { | |
| 331 | errorLabel(error) | |
| 332 | } else if section.records.isEmpty { | |
| 333 | Text("No records found") | |
| 334 | .font(.system(.caption, design: .monospaced)) | |
| 335 | .foregroundStyle(.secondary) | |
| 336 | } else { | |
| 337 | dnsRecordRows(section.records) | |
| 338 | } | |
| 339 | ||
| 340 | if !section.wildcardRecords.isEmpty { | |
| 341 | Text("*.\(viewModel.searchedDomain)") | |
| 342 | .font(.system(.caption, design: .monospaced)) | |
| 343 | .fontWeight(.medium) | |
| 344 | .foregroundStyle(.cyan.opacity(0.7)) | |
| 345 | .padding(.top, 4) | |
| 227 | private func shareResults() { | |
| 228 | let text = viewModel.exportText() | |
| 229 | let dateFmt = DateFormatter() | |
| 230 | dateFmt.dateFormat = "yyyyMMdd_HHmmss" | |
| 231 | let timestamp = dateFmt.string(from: Date()) | |
| 232 | let filename = "\(timestamp)_domaindigresults.txt" | |
| 233 | let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) | |
| 346 | 234 | |
| 347 | dnsRecordRows(section.wildcardRecords) | |
| 348 | } | |
| 235 | do { | |
| 236 | try text.write(to: tempURL, atomically: true, encoding: .utf8) | |
| 237 | } catch { | |
| 238 | return | |
| 349 | 239 | } |
| 350 | } | |
| 351 | 240 | |
| 352 | private func dnsRecordRows(_ records: [DNSRecord]) -> some View { | |
| 353 | ForEach(records) { record in | |
| 354 | HStack(alignment: .top) { | |
| 355 | Text(record.value) | |
| 356 | .font(.system(.caption, design: .monospaced)) | |
| 357 | .foregroundStyle(.primary) | |
| 358 | .textSelection(.enabled) | |
| 359 | Spacer() | |
| 360 | Text("TTL \(record.ttl)") | |
| 361 | .font(.system(.caption2, design: .monospaced)) | |
| 362 | .foregroundStyle(.secondary) | |
| 363 | } | |
| 241 | let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) | |
| 242 | guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, | |
| 243 | let rootVC = windowScene.keyWindow?.rootViewController else { return } | |
| 244 | var presenter = rootVC | |
| 245 | while let presented = presenter.presentedViewController { | |
| 246 | presenter = presented | |
| 364 | 247 | } |
| 248 | activityVC.popoverPresentationController?.sourceView = presenter.view | |
| 249 | presenter.present(activityVC, animated: true) | |
| 365 | 250 | } |
| 251 | } | |
| 366 | 252 | |
| 367 | // MARK: - Email Security | |
| 368 | ||
| 369 | @State private var expandedEmailField: String? | |
| 253 | struct SummaryView: View { | |
| 254 | let fields: [SummaryFieldViewData] | |
| 370 | 255 | |
| 371 | private var emailSecuritySection: some View { | |
| 256 | var body: some View { | |
| 372 | 257 | VStack(alignment: .leading, spacing: 12) { |
| 373 | sectionHeader("Email Security") | |
| 374 | ||
| 375 | if viewModel.emailSecurityLoading { | |
| 376 | ProgressView("Checking email records…") | |
| 377 | .frame(maxWidth: .infinity, alignment: .center) | |
| 378 | .padding() | |
| 379 | } else if let error = viewModel.emailSecurityError { | |
| 380 | errorLabel(error) | |
| 381 | } else if let email = viewModel.emailSecurity { | |
| 382 | horizontallyScrollableCard(spacing: 6) { | |
| 383 | emailSecurityRow("SPF", record: email.spf) | |
| 384 | emailSecurityRow("DMARC", record: email.dmarc) | |
| 385 | emailSecurityRow("DKIM", record: email.dkim) | |
| 386 | emailSecurityRow("MTA-STS", mtaSts: email.mtaSts) | |
| 387 | emailSecurityRow("BIMI", record: email.bimi) | |
| 388 | } | |
| 389 | } | |
| 390 | } | |
| 391 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 392 | .padding(.top, 16) | |
| 393 | } | |
| 394 | ||
| 395 | private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View { | |
| 396 | VStack(alignment: .leading, spacing: 2) { | |
| 397 | HStack(spacing: 8) { | |
| 398 | Text(label) | |
| 399 | .font(.system(.caption, design: .monospaced)) | |
| 400 | .fontWeight(.semibold) | |
| 401 | .frame(width: 72, alignment: .leading) | |
| 402 | Text(record.found ? "✓" : "✗") | |
| 403 | .font(.system(.caption, design: .monospaced)) | |
| 404 | .foregroundStyle(record.found ? .green : .red) | |
| 405 | if let value = record.value { | |
| 406 | let isExpanded = expandedEmailField == label | |
| 407 | let displayValue = isExpanded ? value : String(value.prefix(80)) | |
| 408 | Text(displayValue) | |
| 409 | .font(.system(.caption2, design: .monospaced)) | |
| 410 | .foregroundStyle(.primary) | |
| 411 | .textSelection(.enabled) | |
| 412 | .lineLimit(isExpanded ? nil : 1) | |
| 413 | .onTapGesture { | |
| 414 | withAnimation { | |
| 415 | expandedEmailField = isExpanded ? nil : label | |
| 416 | } | |
| 417 | } | |
| 418 | if let selector = record.matchedSelector { | |
| 419 | Text("(selector: \(selector))") | |
| 258 | SectionTitleView(title: "Summary") | |
| 259 | LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { | |
| 260 | ForEach(fields) { field in | |
| 261 | VStack(alignment: .leading, spacing: 4) { | |
| 262 | Text(field.label) | |
| 420 | 263 | .font(.system(.caption2, design: .monospaced)) |
| 421 | 264 | .foregroundStyle(.secondary) |
| 265 | Text(field.value) | |
| 266 | .font(.system(.caption, design: .monospaced)) | |
| 267 | .foregroundStyle(ResultColors.color(for: field.tone)) | |
| 268 | .lineLimit(2) | |
| 269 | .textSelection(.enabled) | |
| 422 | 270 | } |
| 423 | } else { | |
| 424 | Text("No record found") | |
| 425 | .font(.system(.caption2, design: .monospaced)) | |
| 426 | .foregroundStyle(.secondary) | |
| 427 | } | |
| 428 | } | |
| 429 | } | |
| 430 | } | |
| 431 | ||
| 432 | private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View { | |
| 433 | VStack(alignment: .leading, spacing: 2) { | |
| 434 | HStack(spacing: 8) { | |
| 435 | Text(label) | |
| 436 | .font(.system(.caption, design: .monospaced)) | |
| 437 | .fontWeight(.semibold) | |
| 438 | .frame(width: 72, alignment: .leading) | |
| 439 | Text(mtaSts?.txtFound == true ? "✓" : "✗") | |
| 440 | .font(.system(.caption, design: .monospaced)) | |
| 441 | .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) | |
| 442 | if let policyMode = mtaSts?.policyMode { | |
| 443 | Text(policyMode) | |
| 444 | .font(.system(.caption2, design: .monospaced)) | |
| 445 | .foregroundStyle(.primary) | |
| 446 | .textSelection(.enabled) | |
| 447 | } else { | |
| 448 | Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") | |
| 449 | .font(.system(.caption2, design: .monospaced)) | |
| 450 | .foregroundStyle(.secondary) | |
| 271 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 272 | .padding(10) | |
| 273 | .background(Color(.systemGray6).opacity(0.5)) | |
| 274 | .cornerRadius(6) | |
| 451 | 275 | } |
| 452 | 276 | } |
| 453 | 277 | } |
| 454 | 278 | } |
| 279 | } | |
| 455 | 280 | |
| 456 | // MARK: - SSL Results | |
| 281 | struct DomainSectionView: View { | |
| 282 | let rows: [InfoRowViewData] | |
| 457 | 283 | |
| 458 | private var sslResultsSection: some View { | |
| 284 | var body: some View { | |
| 459 | 285 | VStack(alignment: .leading, spacing: 12) { |
| 460 | sectionHeader("SSL / TLS Certificate") | |
| 461 | ||
| 462 | if viewModel.sslLoading { | |
| 463 | ProgressView("Checking certificate…") | |
| 464 | .frame(maxWidth: .infinity, alignment: .center) | |
| 465 | .padding() | |
| 466 | } else if let error = viewModel.sslError { | |
| 467 | errorLabel(error) | |
| 468 | } else if let info = viewModel.sslInfo { | |
| 469 | sslDetail(info, domain: viewModel.searchedDomain) | |
| 286 | SectionTitleView(title: "Domain") | |
| 287 | CardView { | |
| 288 | ForEach(rows) { row in | |
| 289 | LabeledValueRow(row: row) | |
| 290 | } | |
| 470 | 291 | } |
| 471 | 292 | } |
| 472 | .padding(.top, 16) | |
| 473 | 293 | } |
| 294 | } | |
| 474 | 295 | |
| 475 | private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View { | |
| 476 | horizontallyScrollableCard(spacing: 8) { | |
| 477 | certRow("Common Name", info.commonName) | |
| 478 | certRow("Issuer", info.issuer) | |
| 296 | struct DNSSectionView: View { | |
| 297 | let dnssecLabel: String? | |
| 298 | let sections: [DNSRecordSectionViewData] | |
| 299 | let ptrMessage: SectionMessageViewData? | |
| 300 | let loading: Bool | |
| 301 | let sectionError: String? | |
| 479 | 302 | |
| 480 | VStack(alignment: .leading, spacing: 2) { | |
| 481 | Text("SANs") | |
| 482 | .font(.system(.caption2, design: .monospaced)) | |
| 483 | .foregroundStyle(.secondary) | |
| 484 | ForEach(info.subjectAltNames, id: \.self) { san in | |
| 485 | Text(san) | |
| 486 | .font(.system(.caption, design: .monospaced)) | |
| 487 | .textSelection(.enabled) | |
| 303 | var body: some View { | |
| 304 | VStack(alignment: .leading, spacing: 12) { | |
| 305 | HStack(alignment: .top, spacing: 8) { | |
| 306 | SectionTitleView(title: "DNS") | |
| 307 | Spacer() | |
| 308 | if let dnssecLabel { | |
| 309 | Text(dnssecLabel) | |
| 310 | .font(.system(.caption2, design: .monospaced)) | |
| 311 | .foregroundStyle(.secondary) | |
| 312 | .multilineTextAlignment(.trailing) | |
| 488 | 313 | } |
| 489 | 314 | } |
| 490 | 315 | |
| 491 | let formatter = DateFormatter.certDate | |
| 492 | certRow("Valid From", formatter.string(from: info.validFrom)) | |
| 493 | certRow("Valid Until", formatter.string(from: info.validUntil)) | |
| 316 | if loading { | |
| 317 | LoadingCardView(text: "Querying DNS…") | |
| 318 | } else if let sectionError, sections.isEmpty { | |
| 319 | MessageCardView(text: sectionError, isError: true) | |
| 320 | } else { | |
| 321 | ForEach(sections) { section in | |
| 322 | CardView { | |
| 323 | Text(section.title) | |
| 324 | .font(.system(.subheadline, design: .monospaced)) | |
| 325 | .fontWeight(.semibold) | |
| 326 | .foregroundStyle(.cyan) | |
| 327 | ||
| 328 | if let message = section.message { | |
| 329 | MessageRowView(text: message.text, isError: message.isError) | |
| 330 | } | |
| 494 | 331 | |
| 495 | VStack(alignment: .leading, spacing: 2) { | |
| 496 | Text("Days Until Expiry") | |
| 497 | .font(.system(.caption2, design: .monospaced)) | |
| 498 | .foregroundStyle(.secondary) | |
| 499 | Text("\(info.daysUntilExpiry)") | |
| 500 | .font(.system(.caption, design: .monospaced)) | |
| 501 | .fontWeight(.bold) | |
| 502 | .foregroundStyle(expiryColor(info.daysUntilExpiry)) | |
| 503 | } | |
| 332 | ForEach(section.rows) { row in | |
| 333 | LabeledValueRow(row: row) | |
| 334 | } | |
| 504 | 335 | |
| 505 | certRow("Chain Depth", "\(info.chainDepth)") | |
| 506 | if viewModel.hstsLoading { | |
| 507 | hstsLoadingRow | |
| 508 | } else if let hstsPreloaded = viewModel.hstsPreloaded { | |
| 509 | hstsStatusRow(hstsPreloaded) | |
| 510 | } | |
| 511 | if let tlsVersion = info.tlsVersion { | |
| 512 | certRow("TLS Version", tlsVersion) | |
| 513 | } | |
| 514 | if let cipherSuite = info.cipherSuite { | |
| 515 | certRow("Cipher Suite", cipherSuite) | |
| 516 | } | |
| 517 | if !info.chain.isEmpty { | |
| 518 | VStack(alignment: .leading, spacing: 6) { | |
| 519 | Text("Certificate Chain") | |
| 520 | .font(.system(.caption2, design: .monospaced)) | |
| 521 | .foregroundStyle(.secondary) | |
| 522 | ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in | |
| 523 | DisclosureGroup { | |
| 524 | Text(certificate.issuer) | |
| 336 | if let wildcardTitle = section.wildcardTitle { | |
| 337 | Text(wildcardTitle) | |
| 525 | 338 | .font(.system(.caption, design: .monospaced)) |
| 526 | 339 | .foregroundStyle(.secondary) |
| 527 | .textSelection(.enabled) | |
| 528 | } label: { | |
| 529 | Text(certificate.subject) | |
| 530 | .font(.system(.caption, design: .monospaced)) | |
| 531 | .foregroundStyle(.primary) | |
| 532 | .textSelection(.enabled) | |
| 340 | .padding(.top, 4) | |
| 341 | ForEach(section.wildcardRows) { row in | |
| 342 | LabeledValueRow(row: row) | |
| 343 | } | |
| 533 | 344 | } |
| 534 | .tint(index == 0 ? .cyan : .secondary) | |
| 345 | } | |
| 346 | } | |
| 347 | ||
| 348 | if let ptrMessage { | |
| 349 | CardView { | |
| 350 | Text("PTR") | |
| 351 | .font(.system(.subheadline, design: .monospaced)) | |
| 352 | .fontWeight(.semibold) | |
| 353 | .foregroundStyle(.cyan) | |
| 354 | MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) | |
| 535 | 355 | } |
| 536 | 356 | } |
| 537 | 357 | } |
| 538 | Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!) | |
| 539 | .font(.system(.caption, design: .monospaced)) | |
| 540 | .foregroundStyle(.cyan) | |
| 541 | 358 | } |
| 542 | 359 | } |
| 360 | } | |
| 543 | 361 | |
| 544 | // MARK: - HTTP Headers | |
| 362 | struct WebSectionView: View { | |
| 363 | let certificateRows: [InfoRowViewData] | |
| 364 | let sslInfo: SSLCertificateInfo? | |
| 365 | let sslLoading: Bool | |
| 366 | let sslError: String? | |
| 367 | let responseRows: [InfoRowViewData] | |
| 368 | let headers: [HTTPHeader] | |
| 369 | let headersLoading: Bool | |
| 370 | let headersError: String? | |
| 371 | let redirects: [RedirectHopViewData] | |
| 372 | let redirectLoading: Bool | |
| 373 | let redirectError: String? | |
| 374 | let finalURL: String? | |
| 545 | 375 | |
| 546 | private var httpHeadersSection: some View { | |
| 376 | var body: some View { | |
| 547 | 377 | VStack(alignment: .leading, spacing: 12) { |
| 548 | HStack(spacing: 8) { | |
| 549 | sectionHeader("HTTP Headers") | |
| 550 | if let grade = viewModel.httpSecurityGrade { | |
| 551 | Text(grade) | |
| 552 | .font(.system(.caption, design: .monospaced)) | |
| 553 | .foregroundStyle(httpSecurityGradeColor(for: grade)) | |
| 554 | .padding(.horizontal, 8) | |
| 555 | .padding(.vertical, 2) | |
| 556 | .background(httpSecurityGradeColor(for: grade).opacity(0.18)) | |
| 557 | .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) | |
| 378 | SectionTitleView(title: "Web") | |
| 379 | ||
| 380 | CardView { | |
| 381 | Text("TLS") | |
| 382 | .font(.system(.subheadline, design: .monospaced)) | |
| 383 | .fontWeight(.semibold) | |
| 384 | .foregroundStyle(.cyan) | |
| 385 | if sslLoading { | |
| 386 | ProgressView("Checking certificate…") | |
| 387 | } else if let sslError { | |
| 388 | MessageRowView(text: sslError, isError: true) | |
| 389 | } else { | |
| 390 | ForEach(certificateRows) { row in | |
| 391 | LabeledValueRow(row: row) | |
| 392 | } | |
| 393 | if let sslInfo, !sslInfo.subjectAltNames.isEmpty { | |
| 394 | Text("SANs") | |
| 395 | .font(.system(.caption2, design: .monospaced)) | |
| 396 | .foregroundStyle(.secondary) | |
| 397 | ForEach(sslInfo.subjectAltNames, id: \.self) { san in | |
| 398 | Text(san) | |
| 399 | .font(.system(.caption, design: .monospaced)) | |
| 400 | .textSelection(.enabled) | |
| 401 | } | |
| 402 | } | |
| 558 | 403 | } |
| 559 | Spacer() | |
| 560 | 404 | } |
| 561 | 405 | |
| 562 | if viewModel.httpHeadersLoading { | |
| 563 | ProgressView("Fetching headers…") | |
| 564 | .frame(maxWidth: .infinity, alignment: .center) | |
| 565 | .padding() | |
| 566 | } else if let error = viewModel.httpHeadersError { | |
| 567 | errorLabel(error) | |
| 568 | } else { | |
| 569 | horizontallyScrollableCard { | |
| 570 | if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil { | |
| 571 | HStack(alignment: .top, spacing: 0) { | |
| 572 | ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in | |
| 573 | if index > 0 { | |
| 574 | Text(" ") | |
| 575 | .font(.system(.caption, design: .monospaced)) | |
| 576 | } | |
| 577 | Text(part.text) | |
| 578 | .font(.system(.caption, design: .monospaced)) | |
| 579 | .foregroundStyle(part.color) | |
| 580 | } | |
| 581 | if let http3AvailabilityNote { | |
| 582 | Text(" ") | |
| 406 | CardView { | |
| 407 | Text("Headers") | |
| 408 | .font(.system(.subheadline, design: .monospaced)) | |
| 409 | .fontWeight(.semibold) | |
| 410 | .foregroundStyle(.cyan) | |
| 411 | if headersLoading { | |
| 412 | ProgressView("Fetching headers…") | |
| 413 | } else if let headersError { | |
| 414 | MessageRowView(text: headersError, isError: true) | |
| 415 | } else { | |
| 416 | ForEach(responseRows) { row in | |
| 417 | LabeledValueRow(row: row) | |
| 418 | } | |
| 419 | if headers.isEmpty { | |
| 420 | MessageRowView(text: "No HTTP headers returned", isError: false) | |
| 421 | } else { | |
| 422 | ForEach(headers) { header in | |
| 423 | HStack(alignment: .top, spacing: 4) { | |
| 424 | Text(header.name + ":") | |
| 583 | 425 | .font(.system(.caption, design: .monospaced)) |
| 584 | Text(http3AvailabilityNote) | |
| 426 | .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) | |
| 427 | Text(header.value) | |
| 585 | 428 | .font(.system(.caption, design: .monospaced)) |
| 586 | .foregroundStyle(.secondary) | |
| 429 | .foregroundStyle(.primary) | |
| 430 | .textSelection(.enabled) | |
| 587 | 431 | } |
| 588 | 432 | } |
| 589 | 433 | } |
| 590 | ForEach(viewModel.httpHeaders) { header in | |
| 591 | HStack(alignment: .top, spacing: 4) { | |
| 592 | Text(header.name + ":") | |
| 434 | } | |
| 435 | } | |
| 436 | ||
| 437 | CardView { | |
| 438 | Text("Redirects") | |
| 439 | .font(.system(.subheadline, design: .monospaced)) | |
| 440 | .fontWeight(.semibold) | |
| 441 | .foregroundStyle(.cyan) | |
| 442 | if redirectLoading { | |
| 443 | ProgressView("Tracing redirects…") | |
| 444 | } else if let redirectError { | |
| 445 | MessageRowView(text: redirectError, isError: true) | |
| 446 | } else if redirects.isEmpty { | |
| 447 | MessageRowView(text: "No redirect data available", isError: false) | |
| 448 | } else { | |
| 449 | if let finalURL { | |
| 450 | LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary)) | |
| 451 | } | |
| 452 | ForEach(redirects) { redirect in | |
| 453 | HStack(alignment: .top, spacing: 6) { | |
| 454 | Text(redirect.stepLabel) | |
| 593 | 455 | .font(.system(.caption, design: .monospaced)) |
| 594 | .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) | |
| 595 | Text(header.value) | |
| 456 | .foregroundStyle(.secondary) | |
| 457 | .frame(width: 16, alignment: .trailing) | |
| 458 | Text(redirect.statusCode) | |
| 459 | .font(.system(.caption, design: .monospaced)) | |
| 460 | .foregroundStyle(.cyan) | |
| 461 | .frame(width: 36, alignment: .leading) | |
| 462 | Text(redirect.url) | |
| 596 | 463 | .font(.system(.caption, design: .monospaced)) |
| 597 | .foregroundStyle(.primary) | |
| 598 | 464 | .textSelection(.enabled) |
| 465 | if redirect.isFinal { | |
| 466 | Text("(final)") | |
| 467 | .font(.system(.caption2, design: .monospaced)) | |
| 468 | .foregroundStyle(.secondary) | |
| 469 | } | |
| 599 | 470 | } |
| 600 | 471 | } |
| 601 | 472 | } |
| 602 | 473 | } |
| 603 | 474 | } |
| 604 | .padding(.top, 16) | |
| 605 | 475 | } |
| 476 | } | |
| 606 | 477 | |
| 607 | // MARK: - IP Geolocation | |
| 478 | struct EmailSectionView: View { | |
| 479 | let rows: [EmailRowViewData] | |
| 480 | let loading: Bool | |
| 481 | let error: String? | |
| 608 | 482 | |
| 609 | private var ipGeolocationSection: some View { | |
| 483 | var body: some View { | |
| 610 | 484 | VStack(alignment: .leading, spacing: 12) { |
| 611 | sectionHeader("IP Location") | |
| 612 | ||
| 613 | if viewModel.ipGeolocationLoading { | |
| 614 | ProgressView("Looking up location…") | |
| 615 | .frame(maxWidth: .infinity, alignment: .center) | |
| 616 | .padding() | |
| 617 | } else if let geo = viewModel.ipGeolocation { | |
| 618 | ipGeolocationDetail(geo) | |
| 619 | } else if let error = viewModel.ipGeolocationError { | |
| 620 | if error == "No A record available" { | |
| 621 | Text("No location data available") | |
| 622 | .font(.system(.caption, design: .monospaced)) | |
| 623 | .foregroundStyle(.secondary) | |
| 624 | .padding(8) | |
| 485 | SectionTitleView(title: "Email") | |
| 486 | CardView { | |
| 487 | if loading { | |
| 488 | ProgressView("Checking email records…") | |
| 489 | } else if let error { | |
| 490 | MessageRowView(text: error, isError: true) | |
| 491 | } else if rows.isEmpty { | |
| 492 | MessageRowView(text: "No email security records found", isError: false) | |
| 625 | 493 | } else { |
| 626 | errorLabel(error) | |
| 627 | } | |
| 628 | } | |
| 629 | } | |
| 630 | .padding(.top, 16) | |
| 631 | } | |
| 632 | ||
| 633 | private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { | |
| 634 | VStack(alignment: .leading, spacing: 6) { | |
| 635 | horizontallyScrollableContent(spacing: 6) { | |
| 636 | certRow("IP", geo.ip) | |
| 637 | if let org = geo.org { | |
| 638 | certRow("Org / ISP", org) | |
| 639 | } | |
| 640 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 641 | if !location.isEmpty { | |
| 642 | certRow("Location", location) | |
| 643 | } | |
| 644 | } | |
| 645 | ||
| 646 | if let lat = geo.latitude, let lon = geo.longitude { | |
| 647 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) | |
| 648 | Map(initialPosition: .region(MKCoordinateRegion( | |
| 649 | center: coordinate, | |
| 650 | span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) | |
| 651 | ))) { | |
| 652 | Marker(geo.ip, coordinate: coordinate) | |
| 494 | ForEach(rows) { row in | |
| 495 | VStack(alignment: .leading, spacing: 4) { | |
| 496 | HStack(spacing: 8) { | |
| 497 | Text(row.label) | |
| 498 | .font(.system(.caption, design: .monospaced)) | |
| 499 | .foregroundStyle(.cyan) | |
| 500 | .frame(width: 76, alignment: .leading) | |
| 501 | Text(row.status) | |
| 502 | .font(.system(.caption, design: .monospaced)) | |
| 503 | .foregroundStyle(ResultColors.color(for: row.statusTone)) | |
| 504 | } | |
| 505 | Text(row.detail) | |
| 506 | .font(.system(.caption2, design: .monospaced)) | |
| 507 | .foregroundStyle(.primary) | |
| 508 | .textSelection(.enabled) | |
| 509 | if let auxiliaryDetail = row.auxiliaryDetail { | |
| 510 | Text(auxiliaryDetail) | |
| 511 | .font(.system(.caption2, design: .monospaced)) | |
| 512 | .foregroundStyle(.secondary) | |
| 513 | } | |
| 514 | } | |
| 515 | } | |
| 653 | 516 | } |
| 654 | .mapStyle(.standard) | |
| 655 | .frame(maxWidth: .infinity) | |
| 656 | .frame(height: 180) | |
| 657 | .cornerRadius(8) | |
| 658 | 517 | } |
| 659 | 518 | } |
| 660 | .padding(10) | |
| 661 | .background(Color(.systemGray6).opacity(0.5)) | |
| 662 | .cornerRadius(6) | |
| 663 | 519 | } |
| 520 | } | |
| 664 | 521 | |
| 665 | // MARK: - Port Scan | |
| 522 | struct NetworkSectionView: View { | |
| 523 | let reachabilityRows: [ReachabilityRowViewData] | |
| 524 | let reachabilityLoading: Bool | |
| 525 | let reachabilityError: String? | |
| 526 | let locationRows: [InfoRowViewData] | |
| 527 | let geolocation: IPGeolocation? | |
| 528 | let geolocationLoading: Bool | |
| 529 | let geolocationError: String? | |
| 530 | let standardPortRows: [PortScanRowViewData] | |
| 531 | let customPortRows: [PortScanRowViewData] | |
| 532 | let portScanLoading: Bool | |
| 533 | let portScanError: String? | |
| 534 | let customPortScanLoading: Bool | |
| 535 | let customPortScanError: String? | |
| 536 | let isCloudflareProxied: Bool | |
| 537 | @Binding var customPortsExpanded: Bool | |
| 538 | @Binding var customPortInput: String | |
| 539 | let onScanCustomPorts: () -> Void | |
| 666 | 540 | |
| 667 | private var portScanSection: some View { | |
| 541 | var body: some View { | |
| 668 | 542 | VStack(alignment: .leading, spacing: 12) { |
| 669 | sectionHeader("Open Ports") | |
| 670 | ||
| 671 | if viewModel.portScanLoading { | |
| 672 | ProgressView("Scanning ports…") | |
| 673 | .frame(maxWidth: .infinity, alignment: .center) | |
| 674 | .padding() | |
| 675 | } else if let error = viewModel.portScanError { | |
| 676 | errorLabel(error) | |
| 677 | } else { | |
| 678 | VStack(alignment: .leading, spacing: 12) { | |
| 679 | if viewModel.isCloudflareProxied { | |
| 680 | Text("Domain is behind Cloudflare's proxy. Results reflect what CF's edge exposes, not the origin. CF only proxies ports: 80, 443, 2052–2053, 2082–2083, 2086–2087, 2095–2096, 8080, 8443, 8880.") | |
| 681 | .font(.system(.caption2, design: .monospaced)) | |
| 682 | .foregroundStyle(.orange) | |
| 683 | .padding(8) | |
| 684 | .background(Color.orange.opacity(0.1)) | |
| 685 | .cornerRadius(6) | |
| 686 | } | |
| 687 | portScanResultsCard(viewModel.portScanResults) | |
| 543 | SectionTitleView(title: "Network") | |
| 688 | 544 | |
| 689 | DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { | |
| 690 | VStack(alignment: .leading, spacing: 10) { | |
| 691 | TextField("8888, 9000, 27017", text: $customPortInput) | |
| 545 | CardView { | |
| 546 | Text("Reachability") | |
| 547 | .font(.system(.subheadline, design: .monospaced)) | |
| 548 | .fontWeight(.semibold) | |
| 549 | .foregroundStyle(.cyan) | |
| 550 | if reachabilityLoading { | |
| 551 | ProgressView("Checking ports…") | |
| 552 | } else if let reachabilityError { | |
| 553 | MessageRowView(text: reachabilityError, isError: true) | |
| 554 | } else { | |
| 555 | ForEach(reachabilityRows) { row in | |
| 556 | HStack { | |
| 557 | Text(row.portLabel) | |
| 692 | 558 | .font(.system(.caption, design: .monospaced)) |
| 693 | .textInputAutocapitalization(.never) | |
| 694 | .autocorrectionDisabled() | |
| 695 | .keyboardType(.numberPad) | |
| 696 | .padding(10) | |
| 697 | .background(Color(.systemGray6).opacity(0.5)) | |
| 698 | .cornerRadius(6) | |
| 699 | ||
| 700 | Button("Scan") { | |
| 701 | let ports = parsedCustomPorts(from: customPortInput) | |
| 702 | Task { | |
| 703 | await viewModel.runCustomPortScan(ports: ports) | |
| 704 | } | |
| 705 | } | |
| 706 | .buttonStyle(.borderedProminent) | |
| 707 | .tint(.blue) | |
| 708 | .disabled(viewModel.customPortScanLoading) | |
| 709 | ||
| 710 | if viewModel.customPortScanLoading { | |
| 711 | ProgressView("Scanning custom ports…") | |
| 712 | .font(.system(.caption, design: .monospaced)) | |
| 713 | } else if let error = viewModel.customPortScanError { | |
| 714 | errorLabel(error) | |
| 715 | } else if !viewModel.customPortResults.isEmpty { | |
| 716 | portScanResultsCard(viewModel.customPortResults) | |
| 717 | } | |
| 559 | Spacer() | |
| 560 | Text(row.latencyLabel) | |
| 561 | .font(.system(.caption2, design: .monospaced)) | |
| 562 | .foregroundStyle(.secondary) | |
| 563 | Text(row.statusLabel) | |
| 564 | .font(.system(.caption, design: .monospaced)) | |
| 565 | .foregroundStyle(ResultColors.color(for: row.statusTone)) | |
| 718 | 566 | } |
| 719 | .padding(.top, 8) | |
| 720 | 567 | } |
| 721 | .font(.system(.caption, design: .monospaced)) | |
| 722 | .tint(.secondary) | |
| 723 | 568 | } |
| 724 | .padding(10) | |
| 725 | .background(Color(.systemGray6).opacity(0.5)) | |
| 726 | .cornerRadius(6) | |
| 727 | 569 | } |
| 728 | } | |
| 729 | .padding(.top, 16) | |
| 730 | } | |
| 731 | 570 | |
| 732 | // MARK: - Helpers | |
| 571 | CardView { | |
| 572 | Text("Location") | |
| 573 | .font(.system(.subheadline, design: .monospaced)) | |
| 574 | .fontWeight(.semibold) | |
| 575 | .foregroundStyle(.cyan) | |
| 576 | if geolocationLoading { | |
| 577 | ProgressView("Looking up location…") | |
| 578 | } else if let geolocationError, geolocation == nil { | |
| 579 | MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") | |
| 580 | } else if let geolocation { | |
| 581 | ForEach(locationRows) { row in | |
| 582 | LabeledValueRow(row: row) | |
| 583 | } | |
| 584 | if let latitude = geolocation.latitude, let longitude = geolocation.longitude { | |
| 585 | let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) | |
| 586 | Map(initialPosition: .region(MKCoordinateRegion( | |
| 587 | center: coordinate, | |
| 588 | span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) | |
| 589 | ))) { | |
| 590 | Marker(geolocation.ip, coordinate: coordinate) | |
| 591 | } | |
| 592 | .mapStyle(.standard) | |
| 593 | .frame(height: 180) | |
| 594 | .cornerRadius(8) | |
| 595 | } | |
| 596 | } else { | |
| 597 | MessageRowView(text: "No location data available", isError: false) | |
| 598 | } | |
| 599 | } | |
| 733 | 600 | |
| 734 | private func sectionHeader(_ title: String) -> some View { | |
| 735 | Text(title) | |
| 736 | .font(.system(.headline, design: .default)) | |
| 737 | .foregroundStyle(.white) | |
| 738 | } | |
| 601 | CardView { | |
| 602 | Text("Port Scan") | |
| 603 | .font(.system(.subheadline, design: .monospaced)) | |
| 604 | .fontWeight(.semibold) | |
| 605 | .foregroundStyle(.cyan) | |
| 739 | 606 | |
| 740 | private var dnssecStatus: Bool? { | |
| 741 | viewModel.dnsSections.compactMap(\.dnssecSigned).first | |
| 742 | } | |
| 607 | if isCloudflareProxied { | |
| 608 | Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") | |
| 609 | .font(.system(.caption2, design: .monospaced)) | |
| 610 | .foregroundStyle(.orange) | |
| 611 | } | |
| 743 | 612 | |
| 744 | private func certRow(_ label: String, _ value: String) -> some View { | |
| 745 | VStack(alignment: .leading, spacing: 2) { | |
| 746 | Text(label) | |
| 747 | .font(.system(.caption2, design: .monospaced)) | |
| 748 | .foregroundStyle(.secondary) | |
| 749 | Text(value) | |
| 750 | .font(.system(.caption, design: .monospaced)) | |
| 751 | .textSelection(.enabled) | |
| 752 | } | |
| 753 | } | |
| 613 | if portScanLoading { | |
| 614 | ProgressView("Scanning ports…") | |
| 615 | } else if let portScanError, standardPortRows.isEmpty { | |
| 616 | MessageRowView(text: portScanError, isError: true) | |
| 617 | } else { | |
| 618 | Text("Standard Ports") | |
| 619 | .font(.system(.caption, design: .monospaced)) | |
| 620 | .foregroundStyle(.secondary) | |
| 621 | PortRowsView(rows: standardPortRows) | |
| 622 | } | |
| 754 | 623 | |
| 755 | private var hstsLoadingRow: some View { | |
| 756 | VStack(alignment: .leading, spacing: 2) { | |
| 757 | Text("HSTS Preload") | |
| 758 | .font(.system(.caption2, design: .monospaced)) | |
| 759 | .foregroundStyle(.secondary) | |
| 760 | ProgressView() | |
| 761 | .controlSize(.small) | |
| 762 | } | |
| 763 | } | |
| 624 | DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { | |
| 625 | VStack(alignment: .leading, spacing: 10) { | |
| 626 | TextField("8888, 9000, 27017", text: $customPortInput) | |
| 627 | .font(.system(.caption, design: .monospaced)) | |
| 628 | .textInputAutocapitalization(.never) | |
| 629 | .autocorrectionDisabled() | |
| 630 | .keyboardType(.numberPad) | |
| 631 | .padding(10) | |
| 632 | .background(Color(.systemGray6).opacity(0.5)) | |
| 633 | .cornerRadius(6) | |
| 764 | 634 | |
| 765 | private func hstsStatusRow(_ isPreloaded: Bool) -> some View { | |
| 766 | VStack(alignment: .leading, spacing: 2) { | |
| 767 | Text("HSTS Preload") | |
| 768 | .font(.system(.caption2, design: .monospaced)) | |
| 769 | .foregroundStyle(.secondary) | |
| 770 | Text(isPreloaded ? "Preloaded" : "Not preloaded") | |
| 635 | Button("Scan") { | |
| 636 | onScanCustomPorts() | |
| 637 | } | |
| 638 | .buttonStyle(.borderedProminent) | |
| 639 | .disabled(customPortScanLoading) | |
| 640 | ||
| 641 | if customPortScanLoading { | |
| 642 | ProgressView("Scanning custom ports…") | |
| 643 | } else if let customPortScanError { | |
| 644 | MessageRowView(text: customPortScanError, isError: true) | |
| 645 | } else { | |
| 646 | PortRowsView(rows: customPortRows) | |
| 647 | } | |
| 648 | } | |
| 649 | .padding(.top, 8) | |
| 650 | } | |
| 771 | 651 | .font(.system(.caption, design: .monospaced)) |
| 772 | .foregroundStyle(isPreloaded ? .green : .secondary) | |
| 773 | } | |
| 774 | } | |
| 775 | ||
| 776 | private func horizontallyScrollableCard<Content: View>( | |
| 777 | spacing: CGFloat = 4, | |
| 778 | @ViewBuilder content: () -> Content | |
| 779 | ) -> some View { | |
| 780 | horizontallyScrollableContent(spacing: spacing) { | |
| 781 | content() | |
| 782 | } | |
| 783 | .padding(10) | |
| 784 | .background(Color(.systemGray6).opacity(0.5)) | |
| 785 | .cornerRadius(6) | |
| 786 | } | |
| 787 | ||
| 788 | private func horizontallyScrollableContent<Content: View>( | |
| 789 | spacing: CGFloat = 4, | |
| 790 | @ViewBuilder content: () -> Content | |
| 791 | ) -> some View { | |
| 792 | ScrollView(.horizontal) { | |
| 793 | VStack(alignment: .leading, spacing: spacing) { | |
| 794 | content() | |
| 652 | .tint(.secondary) | |
| 795 | 653 | } |
| 796 | .scrollTargetLayout() | |
| 797 | 654 | } |
| 798 | .scrollBounceBehavior(.basedOnSize, axes: .horizontal) | |
| 799 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 800 | 655 | } |
| 656 | } | |
| 801 | 657 | |
| 802 | private func errorLabel(_ message: String) -> some View { | |
| 803 | Label(message, systemImage: "exclamationmark.triangle.fill") | |
| 804 | .font(.system(.caption, design: .monospaced)) | |
| 805 | .foregroundStyle(.red) | |
| 806 | .padding(8) | |
| 807 | } | |
| 658 | struct PortRowsView: View { | |
| 659 | let rows: [PortScanRowViewData] | |
| 808 | 660 | |
| 809 | private func portScanResultsCard(_ results: [PortScanResult]) -> some View { | |
| 810 | VStack(alignment: .leading, spacing: 4) { | |
| 811 | ForEach(results) { result in | |
| 661 | var body: some View { | |
| 662 | if rows.isEmpty { | |
| 663 | MessageRowView(text: "No results", isError: false) | |
| 664 | } else { | |
| 665 | ForEach(rows) { row in | |
| 812 | 666 | VStack(alignment: .leading, spacing: 2) { |
| 813 | HStack(spacing: 8) { | |
| 814 | Circle() | |
| 815 | .fill(result.open ? Color.green : Color(.systemGray4)) | |
| 816 | .frame(width: 8, height: 8) | |
| 817 | Text("\(result.port)") | |
| 667 | HStack { | |
| 668 | Text(row.portLabel) | |
| 818 | 669 | .font(.system(.caption, design: .monospaced)) |
| 819 | .lineLimit(1) | |
| 820 | 670 | .frame(width: 52, alignment: .leading) |
| 821 | Text(result.service) | |
| 671 | Text(row.service) | |
| 822 | 672 | .font(.system(.caption, design: .monospaced)) |
| 823 | .foregroundStyle(result.open ? .primary : .secondary) | |
| 673 | .foregroundStyle(.primary) | |
| 824 | 674 | Spacer() |
| 825 | if result.open { | |
| 826 | Text("Open") | |
| 675 | if let durationLabel = row.durationLabel { | |
| 676 | Text(durationLabel) | |
| 827 | 677 | .font(.system(.caption2, design: .monospaced)) |
| 828 | .foregroundStyle(.green) | |
| 678 | .foregroundStyle(.secondary) | |
| 829 | 679 | } |
| 680 | Text(row.statusLabel) | |
| 681 | .font(.system(.caption2, design: .monospaced)) | |
| 682 | .foregroundStyle(ResultColors.color(for: row.statusTone)) | |
| 830 | 683 | } |
| 831 | ||
| 832 | if let banner = result.banner { | |
| 684 | if let banner = row.banner { | |
| 833 | 685 | Text(banner) |
| 834 | 686 | .font(.system(.caption2, design: .monospaced)) |
| 835 | 687 | .foregroundStyle(.secondary) |
| 836 | .lineLimit(1) | |
| 837 | .padding(.leading, 16) | |
| 688 | .padding(.leading, 8) | |
| 838 | 689 | } |
| 839 | 690 | } |
| 840 | 691 | } |
| 841 | 692 | } |
| 842 | 693 | } |
| 694 | } | |
| 843 | 695 | |
| 844 | private func parsedCustomPorts(from input: String) -> [UInt16] { | |
| 845 | let parts = input.split(separator: ",", omittingEmptySubsequences: true) | |
| 846 | var seen = Set<UInt16>() | |
| 847 | var ports: [UInt16] = [] | |
| 848 | ||
| 849 | for part in parts { | |
| 850 | let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) | |
| 851 | guard let value = UInt16(trimmed), seen.insert(value).inserted else { | |
| 852 | continue | |
| 853 | } | |
| 854 | ports.append(value) | |
| 855 | if ports.count == 20 { | |
| 856 | break | |
| 857 | } | |
| 858 | } | |
| 696 | struct SectionTitleView: View { | |
| 697 | let title: String | |
| 859 | 698 | |
| 860 | return ports | |
| 699 | var body: some View { | |
| 700 | Text(title) | |
| 701 | .font(.system(.headline)) | |
| 702 | .foregroundStyle(.white) | |
| 861 | 703 | } |
| 704 | } | |
| 862 | 705 | |
| 863 | private var httpStatusSummaryParts: [(text: String, color: Color)] { | |
| 864 | var parts: [(text: String, color: Color)] = [] | |
| 706 | struct CardView<Content: View>: View { | |
| 707 | let content: Content | |
| 865 | 708 | |
| 866 | if let statusCode = viewModel.httpStatusCode { | |
| 867 | parts.append(("HTTP \(statusCode)", .cyan)) | |
| 868 | } | |
| 869 | if let responseTimeMs = viewModel.httpResponseTimeMs { | |
| 870 | parts.append(("\(responseTimeMs)ms", .secondary)) | |
| 871 | } | |
| 872 | if let httpProtocol = viewModel.httpProtocol { | |
| 873 | parts.append((httpProtocol, .secondary)) | |
| 874 | } | |
| 709 | init(@ViewBuilder content: () -> Content) { | |
| 710 | self.content = content() | |
| 711 | } | |
| 875 | 712 | |
| 876 | return parts | |
| 713 | var body: some View { | |
| 714 | ScrollView(.horizontal) { | |
| 715 | VStack(alignment: .leading, spacing: 6) { | |
| 716 | content | |
| 717 | } | |
| 718 | .scrollTargetLayout() | |
| 719 | } | |
| 720 | .scrollBounceBehavior(.basedOnSize, axes: .horizontal) | |
| 721 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 722 | .padding(10) | |
| 723 | .background(Color(.systemGray6).opacity(0.5)) | |
| 724 | .cornerRadius(6) | |
| 877 | 725 | } |
| 726 | } | |
| 878 | 727 | |
| 879 | private var http3AvailabilityNote: String? { | |
| 880 | guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else { | |
| 881 | return nil | |
| 728 | struct LoadingCardView: View { | |
| 729 | let text: String | |
| 730 | ||
| 731 | var body: some View { | |
| 732 | CardView { | |
| 733 | ProgressView(text) | |
| 734 | .frame(maxWidth: .infinity, alignment: .center) | |
| 882 | 735 | } |
| 883 | return "(HTTP/3 available)" | |
| 884 | 736 | } |
| 737 | } | |
| 738 | ||
| 739 | struct MessageCardView: View { | |
| 740 | let text: String | |
| 741 | let isError: Bool | |
| 885 | 742 | |
| 886 | private func httpSecurityGradeColor(for grade: String) -> Color { | |
| 887 | switch grade { | |
| 888 | case "A", "B": | |
| 889 | .green | |
| 890 | case "C": | |
| 891 | .yellow | |
| 892 | case "D", "F": | |
| 893 | .red | |
| 894 | default: | |
| 895 | .secondary | |
| 743 | var body: some View { | |
| 744 | CardView { | |
| 745 | MessageRowView(text: text, isError: isError) | |
| 896 | 746 | } |
| 897 | 747 | } |
| 748 | } | |
| 749 | ||
| 750 | struct MessageRowView: View { | |
| 751 | let text: String | |
| 752 | let isError: Bool | |
| 898 | 753 | |
| 899 | private func expiryColor(_ days: Int) -> Color { | |
| 900 | if days < 30 { return .red } | |
| 901 | if days < 60 { return .yellow } | |
| 902 | return .green | |
| 754 | var body: some View { | |
| 755 | Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") | |
| 756 | .font(.system(.caption, design: .monospaced)) | |
| 757 | .foregroundStyle(isError ? .red : .secondary) | |
| 903 | 758 | } |
| 759 | } | |
| 904 | 760 | |
| 905 | private func shareResults() { | |
| 906 | let text = viewModel.exportText() | |
| 907 | let dateFmt = DateFormatter() | |
| 908 | dateFmt.dateFormat = "yyyyMMdd_HHmmss" | |
| 909 | let timestamp = dateFmt.string(from: Date()) | |
| 910 | let filename = "\(timestamp)_domaindigresults.txt" | |
| 911 | let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) | |
| 761 | struct LabeledValueRow: View { | |
| 762 | let row: InfoRowViewData | |
| 912 | 763 | |
| 913 | do { | |
| 914 | try text.write(to: tempURL, atomically: true, encoding: .utf8) | |
| 915 | } catch { | |
| 916 | return | |
| 764 | var body: some View { | |
| 765 | VStack(alignment: .leading, spacing: 2) { | |
| 766 | Text(row.label) | |
| 767 | .font(.system(.caption2, design: .monospaced)) | |
| 768 | .foregroundStyle(.secondary) | |
| 769 | Text(row.value) | |
| 770 | .font(.system(.caption, design: .monospaced)) | |
| 771 | .foregroundStyle(ResultColors.color(for: row.tone)) | |
| 772 | .textSelection(.enabled) | |
| 917 | 773 | } |
| 774 | } | |
| 775 | } | |
| 918 | 776 | |
| 919 | let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) | |
| 920 | guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, | |
| 921 | let rootVC = windowScene.keyWindow?.rootViewController else { return } | |
| 922 | var presenter = rootVC | |
| 923 | while let presented = presenter.presentedViewController { | |
| 924 | presenter = presented | |
| 777 | enum ResultColors { | |
| 778 | static func color(for tone: ResultTone) -> Color { | |
| 779 | switch tone { | |
| 780 | case .primary: | |
| 781 | return .primary | |
| 782 | case .secondary: | |
| 783 | return .secondary | |
| 784 | case .success: | |
| 785 | return .green | |
| 786 | case .warning: | |
| 787 | return .yellow | |
| 788 | case .failure: | |
| 789 | return .red | |
| 925 | 790 | } |
| 926 | activityVC.popoverPresentationController?.sourceView = presenter.view | |
| 927 | presenter.present(activityVC, animated: true) | |
| 928 | 791 | } |
| 929 | 792 | } |
| 930 | 793 | |
| 931 | 794 | extension DateFormatter { |
| 932 | 795 | static let certDate: DateFormatter = { |
| 933 | let f = DateFormatter() | |
| 934 | f.dateStyle = .medium | |
| 935 | f.timeStyle = .short | |
| 936 | return f | |
| 796 | let formatter = DateFormatter() | |
| 797 | formatter.dateStyle = .medium | |
| 798 | formatter.timeStyle = .short | |
| 799 | return formatter | |
| 937 | 800 | }() |
| 938 | 801 | } |
| 939 | 802 | |
| @@ -948,10 +811,7 @@ private struct SettingsView: View { | ||
| 948 | 811 | guard resolverOption == .custom else { |
| 949 | 812 | return nil |
| 950 | 813 | } |
| 951 | ||
| 952 | return DNSResolverOption.isValidCustomURL(customResolverURL) | |
| 953 | ? nil | |
| 954 | : "Resolver URL must start with https://" | |
| 814 | return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" | |
| 955 | 815 | } |
| 956 | 816 | |
| 957 | 817 | var body: some View { |
| @@ -981,9 +841,7 @@ private struct SettingsView: View { | ||
| 981 | 841 | .onAppear { |
| 982 | 842 | let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) |
| 983 | 843 | resolverOption = DNSResolverOption.option(for: currentResolverURL) |
| 984 | customResolverURL = resolverOption == .custom | |
| 985 | ? currentResolverURL | |
| 986 | : DNSResolverOption.defaultURLString | |
| 844 | customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString | |
| 987 | 845 | } |
| 988 | 846 | .onChange(of: resolverOption) { _, newValue in |
| 989 | 847 | guard let presetURL = newValue.urlString else { |
| @@ -993,9 +851,7 @@ private struct SettingsView: View { | ||
| 993 | 851 | storedResolverURL = presetURL |
| 994 | 852 | } |
| 995 | 853 | .onChange(of: customResolverURL) { _, newValue in |
| 996 | guard resolverOption == .custom else { | |
| 997 | return | |
| 998 | } | |
| 854 | guard resolverOption == .custom else { return } | |
| 999 | 855 | storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) |
| 1000 | 856 | } |
| 1001 | 857 | } |
DomainDig/DNSLookupService.swift +18 −5
| @@ -57,8 +57,6 @@ enum DNSResolverOption: String, CaseIterable, Identifiable { | ||
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | struct DNSLookupService { |
| 60 | private static let rrsigQueryType = 46 | |
| 61 | private static let dnskeyQueryType = 48 | |
| 62 | 60 | private static let internetClass = 1 |
| 63 | 61 | |
| 64 | 62 | static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { |
| @@ -93,7 +91,7 @@ struct DNSLookupService { | ||
| 93 | 91 | } |
| 94 | 92 | } |
| 95 | 93 | |
| 96 | static func lookupAll(domain: String) async -> [DNSSection] { | |
| 94 | static func lookupAll(domain: String) async -> ServiceResult<[DNSSection]> { | |
| 97 | 95 | typealias Result = ( |
| 98 | 96 | type: DNSRecordType, |
| 99 | 97 | records: [DNSRecord], |
| @@ -109,8 +107,11 @@ struct DNSLookupService { | ||
| 109 | 107 | resolverURLString: resolverURLString |
| 110 | 108 | ) |
| 111 | 109 | |
| 112 | return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in | |
| 110 | let sections = await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in | |
| 113 | 111 | for recordType in DNSRecordType.allCases { |
| 112 | guard recordType != .PTR else { | |
| 113 | continue | |
| 114 | } | |
| 114 | 115 | let shouldQueryWildcard = wildcardTypes.contains(recordType) |
| 115 | 116 | group.addTask { |
| 116 | 117 | var apexRecords: [DNSRecord] = [] |
| @@ -159,6 +160,12 @@ struct DNSLookupService { | ||
| 159 | 160 | (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0) |
| 160 | 161 | } |
| 161 | 162 | } |
| 163 | ||
| 164 | if sections.contains(where: { !$0.records.isEmpty || !$0.wildcardRecords.isEmpty || $0.error != nil }) { | |
| 165 | return .success(sections) | |
| 166 | } | |
| 167 | ||
| 168 | return .empty("No DNS records found") | |
| 162 | 169 | } |
| 163 | 170 | |
| 164 | 171 | private static func lookupResponse( |
| @@ -218,11 +225,17 @@ struct DNSLookupService { | ||
| 218 | 225 | return response.authenticatedData |
| 219 | 226 | } |
| 220 | 227 | |
| 221 | private static func currentResolverURLString() -> String { | |
| 228 | static func currentResolverURLString() -> String { | |
| 222 | 229 | let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey) |
| 223 | 230 | return DNSResolverOption.resolvedURLString(from: storedValue) |
| 224 | 231 | } |
| 225 | 232 | |
| 233 | static func currentResolverDisplayName() -> String { | |
| 234 | let resolverURLString = currentResolverURLString() | |
| 235 | let option = DNSResolverOption.option(for: resolverURLString) | |
| 236 | return option == .custom ? resolverURLString : option.title | |
| 237 | } | |
| 238 | ||
| 226 | 239 | private static func validatedResolverURL(from urlString: String) throws -> URL { |
| 227 | 240 | guard let url = URL(string: urlString) else { |
| 228 | 241 | throw URLError(.badURL) |
DomainDig/DomainViewModel.swift +965 −476
| @@ -1,24 +1,162 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | import SwiftUI |
| 3 | 3 | |
| 4 | enum ResultTone { | |
| 5 | case primary | |
| 6 | case secondary | |
| 7 | case success | |
| 8 | case warning | |
| 9 | case failure | |
| 10 | } | |
| 11 | ||
| 12 | struct SummaryFieldViewData: Identifiable { | |
| 13 | let id = UUID() | |
| 14 | let label: String | |
| 15 | let value: String | |
| 16 | let tone: ResultTone | |
| 17 | } | |
| 18 | ||
| 19 | struct InfoRowViewData: Identifiable { | |
| 20 | let id = UUID() | |
| 21 | let label: String | |
| 22 | let value: String | |
| 23 | let tone: ResultTone | |
| 24 | } | |
| 25 | ||
| 26 | struct SectionMessageViewData { | |
| 27 | let text: String | |
| 28 | let isError: Bool | |
| 29 | } | |
| 30 | ||
| 31 | struct DNSRecordSectionViewData: Identifiable { | |
| 32 | let id = UUID() | |
| 33 | let title: String | |
| 34 | let rows: [InfoRowViewData] | |
| 35 | let wildcardRows: [InfoRowViewData] | |
| 36 | let wildcardTitle: String? | |
| 37 | let message: SectionMessageViewData? | |
| 38 | } | |
| 39 | ||
| 40 | struct EmailRowViewData: Identifiable { | |
| 41 | let id = UUID() | |
| 42 | let label: String | |
| 43 | let status: String | |
| 44 | let statusTone: ResultTone | |
| 45 | let detail: String | |
| 46 | let auxiliaryDetail: String? | |
| 47 | } | |
| 48 | ||
| 49 | struct RedirectHopViewData: Identifiable { | |
| 50 | let id = UUID() | |
| 51 | let stepLabel: String | |
| 52 | let statusCode: String | |
| 53 | let url: String | |
| 54 | let isFinal: Bool | |
| 55 | } | |
| 56 | ||
| 57 | struct ReachabilityRowViewData: Identifiable { | |
| 58 | let id = UUID() | |
| 59 | let portLabel: String | |
| 60 | let latencyLabel: String | |
| 61 | let statusLabel: String | |
| 62 | let statusTone: ResultTone | |
| 63 | } | |
| 64 | ||
| 65 | struct PortScanRowViewData: Identifiable { | |
| 66 | let id = UUID() | |
| 67 | let portLabel: String | |
| 68 | let service: String | |
| 69 | let statusLabel: String | |
| 70 | let statusTone: ResultTone | |
| 71 | let banner: String? | |
| 72 | let durationLabel: String? | |
| 73 | } | |
| 74 | ||
| 75 | struct LookupSnapshot { | |
| 76 | let domain: String | |
| 77 | let timestamp: Date | |
| 78 | let resolverDisplayName: String | |
| 79 | let resolverURLString: String | |
| 80 | let totalLookupDurationMs: Int? | |
| 81 | let dnsSections: [DNSSection] | |
| 82 | let dnsError: String? | |
| 83 | let sslInfo: SSLCertificateInfo? | |
| 84 | let sslError: String? | |
| 85 | let hstsPreloaded: Bool? | |
| 86 | let httpHeaders: [HTTPHeader] | |
| 87 | let httpSecurityGrade: String? | |
| 88 | let httpStatusCode: Int? | |
| 89 | let httpResponseTimeMs: Int? | |
| 90 | let httpProtocol: String? | |
| 91 | let http3Advertised: Bool | |
| 92 | let httpHeadersError: String? | |
| 93 | let reachabilityResults: [PortReachability] | |
| 94 | let reachabilityError: String? | |
| 95 | let ipGeolocation: IPGeolocation? | |
| 96 | let ipGeolocationError: String? | |
| 97 | let emailSecurity: EmailSecurityResult? | |
| 98 | let emailSecurityError: String? | |
| 99 | let ptrRecord: String? | |
| 100 | let ptrError: String? | |
| 101 | let redirectChain: [RedirectHop] | |
| 102 | let redirectChainError: String? | |
| 103 | let portScanResults: [PortScanResult] | |
| 104 | let portScanError: String? | |
| 105 | let isLive: Bool | |
| 106 | } | |
| 107 | ||
| 108 | extension HistoryEntry { | |
| 109 | var snapshot: LookupSnapshot { | |
| 110 | LookupSnapshot( | |
| 111 | domain: domain, | |
| 112 | timestamp: timestamp, | |
| 113 | resolverDisplayName: resolverDisplayName, | |
| 114 | resolverURLString: resolverURLString, | |
| 115 | totalLookupDurationMs: totalLookupDurationMs, | |
| 116 | dnsSections: dnsSections, | |
| 117 | dnsError: nil, | |
| 118 | sslInfo: sslInfo, | |
| 119 | sslError: sslError, | |
| 120 | hstsPreloaded: hstsPreloaded, | |
| 121 | httpHeaders: httpHeaders, | |
| 122 | httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue, | |
| 123 | httpStatusCode: nil, | |
| 124 | httpResponseTimeMs: nil, | |
| 125 | httpProtocol: nil, | |
| 126 | http3Advertised: false, | |
| 127 | httpHeadersError: httpHeadersError, | |
| 128 | reachabilityResults: reachabilityResults, | |
| 129 | reachabilityError: reachabilityError, | |
| 130 | ipGeolocation: ipGeolocation, | |
| 131 | ipGeolocationError: ipGeolocationError, | |
| 132 | emailSecurity: emailSecurity, | |
| 133 | emailSecurityError: emailSecurityError, | |
| 134 | ptrRecord: ptrRecord, | |
| 135 | ptrError: ptrError, | |
| 136 | redirectChain: redirectChain, | |
| 137 | redirectChainError: redirectChainError, | |
| 138 | portScanResults: portScanResults, | |
| 139 | portScanError: portScanError, | |
| 140 | isLive: false | |
| 141 | ) | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 4 | 145 | @MainActor |
| 5 | 146 | @Observable |
| 6 | 147 | final class DomainViewModel { |
| 7 | 148 | var domain: String = "" |
| 8 | 149 | |
| 9 | // DNS | |
| 10 | 150 | var dnsSections: [DNSSection] = [] |
| 11 | 151 | var dnsLoading = false |
| 12 | 152 | var dnsError: String? |
| 13 | 153 | |
| 14 | // SSL | |
| 15 | 154 | var sslInfo: SSLCertificateInfo? |
| 16 | 155 | var sslLoading = false |
| 17 | 156 | var sslError: String? |
| 18 | 157 | var hstsPreloaded: Bool? |
| 19 | 158 | var hstsLoading = false |
| 20 | 159 | |
| 21 | // HTTP Headers | |
| 22 | 160 | var httpHeaders: [HTTPHeader] = [] |
| 23 | 161 | var httpSecurityGrade: String? |
| 24 | 162 | var httpStatusCode: Int? |
| @@ -28,32 +166,26 @@ final class DomainViewModel { | ||
| 28 | 166 | var httpHeadersLoading = false |
| 29 | 167 | var httpHeadersError: String? |
| 30 | 168 | |
| 31 | // Reachability | |
| 32 | 169 | var reachabilityResults: [PortReachability] = [] |
| 33 | 170 | var reachabilityLoading = false |
| 34 | 171 | var reachabilityError: String? |
| 35 | 172 | |
| 36 | // IP Geolocation | |
| 37 | 173 | var ipGeolocation: IPGeolocation? |
| 38 | 174 | var ipGeolocationLoading = false |
| 39 | 175 | var ipGeolocationError: String? |
| 40 | 176 | |
| 41 | // Email Security | |
| 42 | 177 | var emailSecurity: EmailSecurityResult? |
| 43 | 178 | var emailSecurityLoading = false |
| 44 | 179 | var emailSecurityError: String? |
| 45 | 180 | |
| 46 | // PTR / Reverse DNS | |
| 47 | 181 | var ptrRecord: String? |
| 48 | 182 | var ptrLoading = false |
| 49 | 183 | var ptrError: String? |
| 50 | 184 | |
| 51 | // Redirect Chain | |
| 52 | 185 | var redirectChain: [RedirectHop] = [] |
| 53 | 186 | var redirectChainLoading = false |
| 54 | 187 | var redirectChainError: String? |
| 55 | 188 | |
| 56 | // Port Scan | |
| 57 | 189 | var portScanResults: [PortScanResult] = [] |
| 58 | 190 | var portScanLoading = false |
| 59 | 191 | var portScanError: String? |
| @@ -63,368 +195,494 @@ final class DomainViewModel { | ||
| 63 | 195 | |
| 64 | 196 | var hasRun = false |
| 65 | 197 | private(set) var searchedDomain: String = "" |
| 198 | private(set) var lastLookupDurationMs: Int? | |
| 66 | 199 | |
| 67 | // MARK: - Recent Searches | |
| 200 | private var lookupTask: Task<Void, Never>? | |
| 201 | private var customPortScanTask: Task<Void, Never>? | |
| 202 | private var activeLookupID = UUID() | |
| 203 | private var lookupStartedAt: Date? | |
| 68 | 204 | |
| 69 | 205 | private static let recentSearchesKey = "recentSearches" |
| 70 | 206 | private static let maxRecent = 20 |
| 71 | ||
| 72 | 207 | var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] |
| 73 | 208 | |
| 74 | // MARK: - Saved Domains | |
| 75 | ||
| 76 | 209 | private static let savedDomainsKey = "savedDomains" |
| 77 | ||
| 78 | 210 | var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] |
| 79 | 211 | |
| 80 | var isCurrentDomainSaved: Bool { | |
| 81 | !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) | |
| 212 | private static let historyKey = "lookupHistory" | |
| 213 | private static let maxHistory = 50 | |
| 214 | var history: [HistoryEntry] = { | |
| 215 | guard let data = UserDefaults.standard.data(forKey: historyKey), | |
| 216 | let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { | |
| 217 | return [] | |
| 218 | } | |
| 219 | return entries | |
| 220 | }() | |
| 221 | ||
| 222 | var trimmedDomain: String { | |
| 223 | domain | |
| 224 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 225 | .replacingOccurrences(of: "https://", with: "") | |
| 226 | .replacingOccurrences(of: "http://", with: "") | |
| 227 | .components(separatedBy: "/").first ?? "" | |
| 82 | 228 | } |
| 83 | 229 | |
| 84 | func toggleSavedDomain() { | |
| 85 | if isCurrentDomainSaved { | |
| 86 | savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() } | |
| 87 | } else { | |
| 88 | savedDomains.append(searchedDomain) | |
| 89 | } | |
| 90 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) | |
| 230 | var resultsLoaded: Bool { | |
| 231 | hasRun && | |
| 232 | !dnsLoading && | |
| 233 | !sslLoading && | |
| 234 | !hstsLoading && | |
| 235 | !httpHeadersLoading && | |
| 236 | !reachabilityLoading && | |
| 237 | !ipGeolocationLoading && | |
| 238 | !emailSecurityLoading && | |
| 239 | !ptrLoading && | |
| 240 | !redirectChainLoading && | |
| 241 | !portScanLoading && | |
| 242 | !customPortScanLoading | |
| 91 | 243 | } |
| 92 | 244 | |
| 93 | func removeSavedDomain(_ domain: String) { | |
| 94 | savedDomains.removeAll { $0 == domain } | |
| 95 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) | |
| 245 | var isCloudflareProxied: Bool { | |
| 246 | httpHeaders.contains { $0.name.lowercased() == "cf-ray" } | |
| 96 | 247 | } |
| 97 | 248 | |
| 98 | func removeSavedDomains(at offsets: IndexSet) { | |
| 99 | savedDomains.remove(atOffsets: offsets) | |
| 100 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) | |
| 249 | var isCurrentDomainSaved: Bool { | |
| 250 | !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) | |
| 101 | 251 | } |
| 102 | 252 | |
| 103 | // MARK: - History | |
| 253 | var resolverDisplayName: String { | |
| 254 | DNSLookupService.currentResolverDisplayName() | |
| 255 | } | |
| 104 | 256 | |
| 105 | private static let historyKey = "lookupHistory" | |
| 106 | private static let maxHistory = 50 | |
| 257 | var resolverURLString: String { | |
| 258 | DNSLookupService.currentResolverURLString() | |
| 259 | } | |
| 107 | 260 | |
| 108 | var history: [HistoryEntry] = { | |
| 109 | guard let data = UserDefaults.standard.data(forKey: "lookupHistory"), | |
| 110 | let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { | |
| 111 | return [] | |
| 261 | var allPortScanResults: [PortScanResult] { | |
| 262 | (portScanResults + customPortResults).sorted { | |
| 263 | if $0.kind == $1.kind { | |
| 264 | return $0.port < $1.port | |
| 265 | } | |
| 266 | return $0.kind == .standard | |
| 112 | 267 | } |
| 113 | return entries | |
| 114 | }() | |
| 268 | } | |
| 115 | 269 | |
| 116 | private func saveHistoryEntry() { | |
| 117 | let entry = HistoryEntry( | |
| 270 | var currentSnapshot: LookupSnapshot { | |
| 271 | LookupSnapshot( | |
| 118 | 272 | domain: searchedDomain, |
| 119 | 273 | timestamp: Date(), |
| 274 | resolverDisplayName: resolverDisplayName, | |
| 275 | resolverURLString: resolverURLString, | |
| 276 | totalLookupDurationMs: lastLookupDurationMs, | |
| 120 | 277 | dnsSections: dnsSections, |
| 278 | dnsError: dnsError, | |
| 121 | 279 | sslInfo: sslInfo, |
| 280 | sslError: sslError, | |
| 281 | hstsPreloaded: hstsPreloaded, | |
| 122 | 282 | httpHeaders: httpHeaders, |
| 283 | httpSecurityGrade: httpSecurityGrade, | |
| 284 | httpStatusCode: httpStatusCode, | |
| 285 | httpResponseTimeMs: httpResponseTimeMs, | |
| 286 | httpProtocol: httpProtocol, | |
| 287 | http3Advertised: http3Advertised, | |
| 288 | httpHeadersError: httpHeadersError, | |
| 123 | 289 | reachabilityResults: reachabilityResults, |
| 290 | reachabilityError: reachabilityError, | |
| 124 | 291 | ipGeolocation: ipGeolocation, |
| 292 | ipGeolocationError: ipGeolocationError, | |
| 125 | 293 | emailSecurity: emailSecurity, |
| 126 | mtaSts: emailSecurity?.mtaSts, | |
| 294 | emailSecurityError: emailSecurityError, | |
| 127 | 295 | ptrRecord: ptrRecord, |
| 296 | ptrError: ptrError, | |
| 128 | 297 | redirectChain: redirectChain, |
| 129 | portScanResults: portScanResults, | |
| 130 | hstsPreloaded: hstsPreloaded | |
| 298 | redirectChainError: redirectChainError, | |
| 299 | portScanResults: allPortScanResults, | |
| 300 | portScanError: combinedPortScanError, | |
| 301 | isLive: true | |
| 131 | 302 | ) |
| 132 | history.insert(entry, at: 0) | |
| 133 | if history.count > Self.maxHistory { | |
| 134 | history = Array(history.prefix(Self.maxHistory)) | |
| 135 | } | |
| 136 | if let data = try? JSONEncoder().encode(history) { | |
| 137 | UserDefaults.standard.set(data, forKey: Self.historyKey) | |
| 138 | } | |
| 139 | 303 | } |
| 140 | 304 | |
| 141 | func removeHistoryEntries(at offsets: IndexSet) { | |
| 142 | history.remove(atOffsets: offsets) | |
| 143 | if let data = try? JSONEncoder().encode(history) { | |
| 144 | UserDefaults.standard.set(data, forKey: Self.historyKey) | |
| 145 | } | |
| 305 | var summaryFields: [SummaryFieldViewData] { | |
| 306 | Self.summaryFields(from: currentSnapshot) | |
| 146 | 307 | } |
| 147 | 308 | |
| 148 | // MARK: - Computed | |
| 309 | var domainRows: [InfoRowViewData] { | |
| 310 | Self.domainRows(from: currentSnapshot) | |
| 311 | } | |
| 149 | 312 | |
| 150 | var trimmedDomain: String { | |
| 151 | domain | |
| 152 | .trimmingCharacters(in: .whitespacesAndNewlines) | |
| 153 | .replacingOccurrences(of: "https://", with: "") | |
| 154 | .replacingOccurrences(of: "http://", with: "") | |
| 155 | .components(separatedBy: "/").first ?? "" | |
| 313 | var dnsRows: [DNSRecordSectionViewData] { | |
| 314 | Self.dnsRows(from: currentSnapshot) | |
| 156 | 315 | } |
| 157 | 316 | |
| 158 | /// True when all lookups have finished (regardless of success/failure). | |
| 159 | var resultsLoaded: Bool { | |
| 160 | hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading | |
| 161 | && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading | |
| 162 | && !redirectChainLoading && !portScanLoading | |
| 317 | var dnssecLabel: String? { | |
| 318 | Self.dnssecLabel(from: currentSnapshot) | |
| 163 | 319 | } |
| 164 | 320 | |
| 165 | /// True when response headers indicate the domain is behind Cloudflare's proxy. | |
| 166 | /// Cloudflare injects cf-ray on all proxied (orange-cloud) responses. Grey-cloud | |
| 167 | /// (DNS-only) domains won't have this header because traffic doesn't pass through CF's edge. | |
| 168 | var isCloudflareProxied: Bool { | |
| 169 | httpHeaders.contains { $0.name.lowercased() == "cf-ray" } | |
| 321 | var ptrMessage: SectionMessageViewData? { | |
| 322 | Self.ptrMessage(from: currentSnapshot) | |
| 323 | } | |
| 324 | ||
| 325 | var webCertificateRows: [InfoRowViewData] { | |
| 326 | Self.webCertificateRows(from: currentSnapshot) | |
| 327 | } | |
| 328 | ||
| 329 | var webResponseRows: [InfoRowViewData] { | |
| 330 | Self.webResponseRows(from: currentSnapshot) | |
| 170 | 331 | } |
| 171 | 332 | |
| 172 | // MARK: - Reset | |
| 333 | var redirectRows: [RedirectHopViewData] { | |
| 334 | Self.redirectRows(from: currentSnapshot) | |
| 335 | } | |
| 336 | ||
| 337 | var emailRows: [EmailRowViewData] { | |
| 338 | Self.emailRows(from: currentSnapshot) | |
| 339 | } | |
| 340 | ||
| 341 | var reachabilityRows: [ReachabilityRowViewData] { | |
| 342 | Self.reachabilityRows(from: currentSnapshot) | |
| 343 | } | |
| 344 | ||
| 345 | var locationRows: [InfoRowViewData] { | |
| 346 | Self.locationRows(from: currentSnapshot) | |
| 347 | } | |
| 348 | ||
| 349 | var standardPortRows: [PortScanRowViewData] { | |
| 350 | Self.portRows(from: currentSnapshot, kind: .standard) | |
| 351 | } | |
| 352 | ||
| 353 | var customPortRows: [PortScanRowViewData] { | |
| 354 | Self.portRows(from: currentSnapshot, kind: .custom) | |
| 355 | } | |
| 356 | ||
| 357 | var combinedPortScanError: String? { | |
| 358 | [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty | |
| 359 | } | |
| 360 | ||
| 361 | func toggleSavedDomain() { | |
| 362 | if isCurrentDomainSaved { | |
| 363 | savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() } | |
| 364 | } else { | |
| 365 | savedDomains.append(searchedDomain) | |
| 366 | } | |
| 367 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) | |
| 368 | } | |
| 369 | ||
| 370 | func removeSavedDomains(at offsets: IndexSet) { | |
| 371 | savedDomains.remove(atOffsets: offsets) | |
| 372 | UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) | |
| 373 | } | |
| 374 | ||
| 375 | func removeHistoryEntries(at offsets: IndexSet) { | |
| 376 | history.remove(atOffsets: offsets) | |
| 377 | persistHistory() | |
| 378 | } | |
| 379 | ||
| 380 | func clearRecentSearches() { | |
| 381 | recentSearches.removeAll() | |
| 382 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) | |
| 383 | } | |
| 384 | ||
| 385 | func rerunLookup(from entry: HistoryEntry) { | |
| 386 | UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) | |
| 387 | domain = entry.domain | |
| 388 | run() | |
| 389 | } | |
| 173 | 390 | |
| 174 | 391 | func reset() { |
| 392 | lookupTask?.cancel() | |
| 393 | customPortScanTask?.cancel() | |
| 175 | 394 | hasRun = false |
| 176 | 395 | searchedDomain = "" |
| 177 | dnsSections = [] | |
| 178 | dnsError = nil | |
| 179 | dnsLoading = false | |
| 180 | sslInfo = nil | |
| 181 | sslError = nil | |
| 182 | sslLoading = false | |
| 183 | hstsPreloaded = nil | |
| 184 | hstsLoading = false | |
| 185 | httpHeaders = [] | |
| 186 | httpSecurityGrade = nil | |
| 187 | httpStatusCode = nil | |
| 188 | httpResponseTimeMs = nil | |
| 189 | httpProtocol = nil | |
| 190 | http3Advertised = false | |
| 191 | httpHeadersError = nil | |
| 192 | httpHeadersLoading = false | |
| 193 | reachabilityResults = [] | |
| 194 | reachabilityError = nil | |
| 195 | reachabilityLoading = false | |
| 196 | ipGeolocation = nil | |
| 197 | ipGeolocationError = nil | |
| 198 | ipGeolocationLoading = false | |
| 199 | emailSecurity = nil | |
| 200 | emailSecurityError = nil | |
| 201 | emailSecurityLoading = false | |
| 202 | ptrRecord = nil | |
| 203 | ptrError = nil | |
| 204 | ptrLoading = false | |
| 205 | redirectChain = [] | |
| 206 | redirectChainError = nil | |
| 207 | redirectChainLoading = false | |
| 208 | portScanResults = [] | |
| 209 | portScanError = nil | |
| 210 | portScanLoading = false | |
| 211 | customPortResults = [] | |
| 212 | customPortScanError = nil | |
| 213 | customPortScanLoading = false | |
| 396 | lastLookupDurationMs = nil | |
| 397 | clearLookupState() | |
| 214 | 398 | } |
| 215 | 399 | |
| 216 | // MARK: - Run | |
| 217 | ||
| 218 | 400 | func run() { |
| 219 | 401 | let target = trimmedDomain |
| 220 | 402 | guard !target.isEmpty else { return } |
| 221 | 403 | |
| 404 | lookupTask?.cancel() | |
| 405 | customPortScanTask?.cancel() | |
| 406 | ||
| 407 | let lookupID = UUID() | |
| 408 | activeLookupID = lookupID | |
| 409 | lookupStartedAt = Date() | |
| 410 | lastLookupDurationMs = nil | |
| 222 | 411 | addRecentSearch(target) |
| 223 | 412 | searchedDomain = target |
| 224 | 413 | hasRun = true |
| 414 | clearLookupState() | |
| 415 | setAllLoadingStates(true) | |
| 416 | customPortScanLoading = false | |
| 225 | 417 | |
| 226 | // Reset all state | |
| 227 | dnsSections = [] | |
| 228 | dnsError = nil | |
| 229 | dnsLoading = true | |
| 230 | sslInfo = nil | |
| 231 | sslError = nil | |
| 232 | sslLoading = true | |
| 233 | hstsPreloaded = nil | |
| 234 | hstsLoading = true | |
| 235 | httpHeaders = [] | |
| 236 | httpSecurityGrade = nil | |
| 237 | httpStatusCode = nil | |
| 238 | httpResponseTimeMs = nil | |
| 239 | httpProtocol = nil | |
| 240 | http3Advertised = false | |
| 241 | httpHeadersError = nil | |
| 242 | httpHeadersLoading = true | |
| 243 | reachabilityResults = [] | |
| 244 | reachabilityError = nil | |
| 245 | reachabilityLoading = true | |
| 246 | ipGeolocation = nil | |
| 247 | ipGeolocationError = nil | |
| 248 | ipGeolocationLoading = true | |
| 249 | emailSecurity = nil | |
| 250 | emailSecurityError = nil | |
| 251 | emailSecurityLoading = true | |
| 252 | ptrRecord = nil | |
| 253 | ptrError = nil | |
| 254 | ptrLoading = true | |
| 255 | redirectChain = [] | |
| 256 | redirectChainError = nil | |
| 257 | redirectChainLoading = true | |
| 258 | portScanResults = [] | |
| 259 | portScanError = nil | |
| 260 | portScanLoading = true | |
| 261 | customPortResults = [] | |
| 418 | lookupTask = Task { [weak self] in | |
| 419 | guard let self else { return } | |
| 420 | await self.performLookup(domain: target, lookupID: lookupID) | |
| 421 | } | |
| 422 | } | |
| 423 | ||
| 424 | func runCustomPortScan(ports: [UInt16]) async { | |
| 425 | guard !searchedDomain.isEmpty else { | |
| 426 | customPortScanError = "Run a domain lookup first" | |
| 427 | return | |
| 428 | } | |
| 429 | ||
| 430 | guard !ports.isEmpty else { | |
| 431 | customPortScanError = "Enter at least one valid port" | |
| 432 | customPortResults = [] | |
| 433 | return | |
| 434 | } | |
| 435 | ||
| 436 | customPortScanTask?.cancel() | |
| 437 | let domain = searchedDomain | |
| 438 | let lookupID = activeLookupID | |
| 439 | ||
| 440 | customPortScanLoading = true | |
| 262 | 441 | customPortScanError = nil |
| 263 | customPortScanLoading = false | |
| 442 | customPortResults = [] | |
| 264 | 443 | |
| 265 | Task { | |
| 266 | await withTaskGroup(of: Void.self) { group in | |
| 267 | // DNS → chained: email security, PTR, geolocation | |
| 268 | group.addTask { @MainActor in | |
| 269 | await self.runDNS(domain: target) | |
| 270 | // These depend on DNS results and run in parallel after DNS | |
| 271 | await withTaskGroup(of: Void.self) { postDNS in | |
| 272 | postDNS.addTask { @MainActor in | |
| 273 | await self.runEmailSecurity(domain: target) | |
| 274 | } | |
| 275 | postDNS.addTask { @MainActor in | |
| 276 | await self.runReverseDNS() | |
| 277 | } | |
| 278 | postDNS.addTask { @MainActor in | |
| 279 | await self.runIPGeolocation() | |
| 280 | } | |
| 281 | } | |
| 282 | } | |
| 283 | group.addTask { @MainActor in | |
| 284 | await self.runSSL(domain: target) | |
| 285 | } | |
| 286 | group.addTask { @MainActor in | |
| 287 | await self.runHSTSPreload(domain: target) | |
| 288 | } | |
| 289 | group.addTask { @MainActor in | |
| 290 | await self.runHTTPHeaders(domain: target) | |
| 291 | } | |
| 292 | group.addTask { @MainActor in | |
| 293 | await self.runReachability(domain: target) | |
| 294 | } | |
| 295 | group.addTask { @MainActor in | |
| 296 | await self.runRedirectChain(domain: target) | |
| 297 | } | |
| 298 | group.addTask { @MainActor in | |
| 299 | await self.runPortScan(domain: target) | |
| 300 | } | |
| 301 | } | |
| 302 | // Save history after all lookups complete so the snapshot is complete | |
| 303 | saveHistoryEntry() | |
| 444 | customPortScanTask = Task { [weak self] in | |
| 445 | guard let self else { return } | |
| 446 | let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0) | |
| 447 | guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return } | |
| 448 | self.applyCustomPortResult(result) | |
| 304 | 449 | } |
| 305 | 450 | } |
| 306 | 451 | |
| 307 | // MARK: - Lookup Methods | |
| 452 | func exportText() -> String { | |
| 453 | Self.formatExportText(from: currentSnapshot) | |
| 454 | } | |
| 455 | ||
| 456 | private func performLookup(domain: String, lookupID: UUID) async { | |
| 457 | await withTaskGroup(of: Void.self) { group in | |
| 458 | group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } | |
| 459 | group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } | |
| 460 | group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } | |
| 461 | group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } | |
| 462 | group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) } | |
| 463 | group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) } | |
| 464 | group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) } | |
| 465 | } | |
| 466 | ||
| 467 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 468 | ||
| 469 | let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] | |
| 470 | let primaryIP = primaryIPAddress(from: dnsSections) | |
| 471 | ||
| 472 | await withTaskGroup(of: Void.self) { group in | |
| 473 | group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) } | |
| 474 | if let primaryIP { | |
| 475 | group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) } | |
| 476 | group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) } | |
| 477 | } else { | |
| 478 | group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) } | |
| 479 | } | |
| 480 | } | |
| 481 | ||
| 482 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 483 | lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } | |
| 484 | saveHistoryEntry(replaceLatest: false) | |
| 485 | } | |
| 308 | 486 | |
| 309 | private func runDNS(domain: String) async { | |
| 310 | do { | |
| 311 | let sections = await DNSLookupService.lookupAll(domain: domain) | |
| 487 | private func runDNS(domain: String, lookupID: UUID) async { | |
| 488 | let result = await DNSLookupService.lookupAll(domain: domain) | |
| 489 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 490 | switch result { | |
| 491 | case let .success(sections): | |
| 312 | 492 | dnsSections = sections |
| 493 | dnsError = nil | |
| 494 | case let .empty(message): | |
| 495 | dnsSections = [] | |
| 496 | dnsError = message | |
| 497 | case let .error(message): | |
| 498 | dnsSections = [] | |
| 499 | dnsError = message | |
| 313 | 500 | } |
| 314 | 501 | dnsLoading = false |
| 315 | 502 | } |
| 316 | 503 | |
| 317 | private func runSSL(domain: String) async { | |
| 318 | do { | |
| 319 | let info = try await SSLCheckService.check(domain: domain) | |
| 504 | private func runSSL(domain: String, lookupID: UUID) async { | |
| 505 | let result = await SSLCheckService.check(domain: domain) | |
| 506 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 507 | switch result { | |
| 508 | case let .success(info): | |
| 320 | 509 | sslInfo = info |
| 321 | } catch { | |
| 322 | sslError = error.localizedDescription | |
| 510 | sslError = nil | |
| 511 | case let .empty(message): | |
| 512 | sslInfo = nil | |
| 513 | sslError = message | |
| 514 | case let .error(message): | |
| 515 | sslInfo = nil | |
| 516 | sslError = message | |
| 323 | 517 | } |
| 324 | 518 | sslLoading = false |
| 325 | 519 | } |
| 326 | 520 | |
| 327 | private func runHSTSPreload(domain: String) async { | |
| 328 | hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain) | |
| 521 | private func runHSTSPreload(domain: String, lookupID: UUID) async { | |
| 522 | let result = await SSLCheckService.checkHSTSPreload(domain: domain) | |
| 523 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 524 | hstsPreloaded = result | |
| 329 | 525 | hstsLoading = false |
| 330 | 526 | } |
| 331 | 527 | |
| 332 | private func runHTTPHeaders(domain: String) async { | |
| 333 | do { | |
| 334 | let result = try await HTTPHeadersService.fetch(domain: domain) | |
| 335 | httpHeaders = result.headers | |
| 336 | httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue | |
| 337 | httpStatusCode = result.statusCode | |
| 338 | httpResponseTimeMs = result.responseTimeMs | |
| 339 | httpProtocol = result.httpProtocol | |
| 340 | http3Advertised = result.http3Advertised | |
| 341 | } catch { | |
| 342 | httpHeadersError = error.localizedDescription | |
| 528 | private func runHTTPHeaders(domain: String, lookupID: UUID) async { | |
| 529 | let result = await HTTPHeadersService.fetch(domain: domain) | |
| 530 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 531 | switch result { | |
| 532 | case let .success(headersResult): | |
| 533 | httpHeaders = headersResult.headers | |
| 534 | httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue | |
| 535 | httpStatusCode = headersResult.statusCode | |
| 536 | httpResponseTimeMs = headersResult.responseTimeMs | |
| 537 | httpProtocol = headersResult.httpProtocol | |
| 538 | http3Advertised = headersResult.http3Advertised | |
| 539 | httpHeadersError = nil | |
| 540 | case let .empty(message): | |
| 541 | httpHeaders = [] | |
| 542 | httpSecurityGrade = nil | |
| 543 | httpStatusCode = nil | |
| 544 | httpResponseTimeMs = nil | |
| 545 | httpProtocol = nil | |
| 546 | http3Advertised = false | |
| 547 | httpHeadersError = message | |
| 548 | case let .error(message): | |
| 549 | httpHeaders = [] | |
| 550 | httpSecurityGrade = nil | |
| 551 | httpStatusCode = nil | |
| 552 | httpResponseTimeMs = nil | |
| 553 | httpProtocol = nil | |
| 554 | http3Advertised = false | |
| 555 | httpHeadersError = message | |
| 343 | 556 | } |
| 344 | 557 | httpHeadersLoading = false |
| 345 | 558 | } |
| 346 | 559 | |
| 347 | private func runReachability(domain: String) async { | |
| 348 | let results = await ReachabilityService.checkAll(domain: domain) | |
| 349 | reachabilityResults = results | |
| 350 | reachabilityLoading = false | |
| 351 | } | |
| 352 | ||
| 353 | private func runIPGeolocation() async { | |
| 354 | // Find the first A record IP | |
| 355 | guard let aSection = dnsSections.first(where: { $0.recordType == .A }), | |
| 356 | let firstIP = aSection.records.first?.value else { | |
| 357 | ipGeolocationError = "No A record available" | |
| 358 | ipGeolocationLoading = false | |
| 359 | return | |
| 560 | private func runReachability(domain: String, lookupID: UUID) async { | |
| 561 | let result = await ReachabilityService.checkAll(domain: domain) | |
| 562 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 563 | switch result { | |
| 564 | case let .success(results): | |
| 565 | reachabilityResults = results | |
| 566 | reachabilityError = nil | |
| 567 | case let .empty(message): | |
| 568 | reachabilityResults = [] | |
| 569 | reachabilityError = message | |
| 570 | case let .error(message): | |
| 571 | reachabilityResults = [] | |
| 572 | reachabilityError = message | |
| 360 | 573 | } |
| 361 | do { | |
| 362 | let geo = try await IPGeolocationService.lookup(ip: firstIP) | |
| 363 | ipGeolocation = geo | |
| 364 | } catch { | |
| 365 | ipGeolocationError = error.localizedDescription | |
| 366 | } | |
| 367 | ipGeolocationLoading = false | |
| 574 | reachabilityLoading = false | |
| 368 | 575 | } |
| 369 | 576 | |
| 370 | private func runEmailSecurity(domain: String) async { | |
| 371 | // Extract TXT records from already-fetched DNS sections | |
| 372 | let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] | |
| 577 | private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async { | |
| 373 | 578 | let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) |
| 374 | emailSecurity = result | |
| 579 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 580 | switch result { | |
| 581 | case let .success(emailResult): | |
| 582 | emailSecurity = emailResult | |
| 583 | emailSecurityError = nil | |
| 584 | case let .empty(message): | |
| 585 | emailSecurity = nil | |
| 586 | emailSecurityError = message | |
| 587 | case let .error(message): | |
| 588 | emailSecurity = nil | |
| 589 | emailSecurityError = message | |
| 590 | } | |
| 375 | 591 | emailSecurityLoading = false |
| 376 | 592 | } |
| 377 | 593 | |
| 378 | private func runReverseDNS() async { | |
| 379 | guard let aSection = dnsSections.first(where: { $0.recordType == .A }), | |
| 380 | let firstIP = aSection.records.first?.value else { | |
| 381 | ptrError = "No A record available" | |
| 382 | ptrLoading = false | |
| 383 | return | |
| 384 | } | |
| 385 | let result = await ReverseDNSService.lookup(ip: firstIP) | |
| 386 | ptrRecord = result | |
| 387 | if result == nil { | |
| 388 | ptrError = "No PTR record found" | |
| 594 | private func runReverseDNS(ip: String, lookupID: UUID) async { | |
| 595 | let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString) | |
| 596 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 597 | switch result { | |
| 598 | case let .success(record): | |
| 599 | ptrRecord = record | |
| 600 | ptrError = nil | |
| 601 | case let .empty(message): | |
| 602 | ptrRecord = nil | |
| 603 | ptrError = message | |
| 604 | case let .error(message): | |
| 605 | ptrRecord = nil | |
| 606 | ptrError = message | |
| 389 | 607 | } |
| 390 | 608 | ptrLoading = false |
| 391 | 609 | } |
| 392 | 610 | |
| 393 | private func runRedirectChain(domain: String) async { | |
| 394 | do { | |
| 395 | let hops = try await RedirectChainService.trace(domain: domain) | |
| 611 | private func runRedirectChain(domain: String, lookupID: UUID) async { | |
| 612 | let result = await RedirectChainService.trace(domain: domain) | |
| 613 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 614 | switch result { | |
| 615 | case let .success(hops): | |
| 396 | 616 | redirectChain = hops |
| 397 | } catch { | |
| 398 | redirectChainError = error.localizedDescription | |
| 617 | redirectChainError = nil | |
| 618 | case let .empty(message): | |
| 619 | redirectChain = [] | |
| 620 | redirectChainError = message | |
| 621 | case let .error(message): | |
| 622 | redirectChain = [] | |
| 623 | redirectChainError = message | |
| 399 | 624 | } |
| 400 | 625 | redirectChainLoading = false |
| 401 | 626 | } |
| 402 | 627 | |
| 403 | private func runPortScan(domain: String) async { | |
| 404 | let results = await PortScanService.scanAll(domain: domain) | |
| 405 | let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain) | |
| 406 | portScanResults = enrichedResults | |
| 628 | private func runPortScan(domain: String, lookupID: UUID) async { | |
| 629 | let result = await PortScanService.scanAll(domain: domain) | |
| 630 | switch result { | |
| 631 | case let .success(results): | |
| 632 | let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain) | |
| 633 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 634 | portScanResults = enrichedResults | |
| 635 | portScanError = nil | |
| 636 | case let .empty(message): | |
| 637 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 638 | portScanResults = [] | |
| 639 | portScanError = message | |
| 640 | case let .error(message): | |
| 641 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 642 | portScanResults = [] | |
| 643 | portScanError = message | |
| 644 | } | |
| 407 | 645 | portScanLoading = false |
| 408 | 646 | } |
| 409 | 647 | |
| 410 | func runCustomPortScan(ports: [UInt16]) async { | |
| 411 | guard !searchedDomain.isEmpty else { | |
| 412 | customPortScanError = "Run a domain lookup first" | |
| 413 | return | |
| 648 | private func runIPGeolocation(ip: String, lookupID: UUID) async { | |
| 649 | let result = await IPGeolocationService.lookup(ip: ip) | |
| 650 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 651 | switch result { | |
| 652 | case let .success(geolocation): | |
| 653 | ipGeolocation = geolocation | |
| 654 | ipGeolocationError = nil | |
| 655 | case let .empty(message): | |
| 656 | ipGeolocation = nil | |
| 657 | ipGeolocationError = message | |
| 658 | case let .error(message): | |
| 659 | ipGeolocation = nil | |
| 660 | ipGeolocationError = message | |
| 414 | 661 | } |
| 662 | ipGeolocationLoading = false | |
| 663 | } | |
| 415 | 664 | |
| 416 | guard !ports.isEmpty else { | |
| 417 | customPortScanError = "Enter at least one valid port" | |
| 665 | private func finishDependentWithoutPrimaryIP(lookupID: UUID) async { | |
| 666 | guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } | |
| 667 | ptrLoading = false | |
| 668 | ptrError = "No A record available" | |
| 669 | ipGeolocationLoading = false | |
| 670 | ipGeolocationError = "No A record available" | |
| 671 | } | |
| 672 | ||
| 673 | private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) { | |
| 674 | switch result { | |
| 675 | case let .success(results): | |
| 676 | customPortResults = results | |
| 677 | customPortScanError = nil | |
| 678 | saveHistoryEntry(replaceLatest: true) | |
| 679 | case let .empty(message): | |
| 418 | 680 | customPortResults = [] |
| 419 | return | |
| 681 | customPortScanError = message | |
| 682 | case let .error(message): | |
| 683 | customPortResults = [] | |
| 684 | customPortScanError = message | |
| 420 | 685 | } |
| 421 | ||
| 422 | customPortScanLoading = true | |
| 423 | customPortScanError = nil | |
| 424 | customPortResults = [] | |
| 425 | ||
| 426 | let results = await PortScanService.scanPorts(domain: searchedDomain, ports: ports, timeout: 3.0) | |
| 427 | customPortResults = results | |
| 428 | 686 | customPortScanLoading = false |
| 429 | 687 | } |
| 430 | 688 | |
| @@ -453,271 +711,502 @@ final class DomainViewModel { | ||
| 453 | 711 | } |
| 454 | 712 | } |
| 455 | 713 | |
| 456 | // MARK: - Export | |
| 457 | ||
| 458 | func exportText() -> String { | |
| 459 | return Self.formatExportText( | |
| 714 | private func saveHistoryEntry(replaceLatest: Bool) { | |
| 715 | guard !searchedDomain.isEmpty else { return } | |
| 716 | let entry = HistoryEntry( | |
| 460 | 717 | domain: searchedDomain, |
| 461 | date: Date(), | |
| 718 | timestamp: Date(), | |
| 462 | 719 | dnsSections: dnsSections, |
| 463 | 720 | sslInfo: sslInfo, |
| 464 | sslError: sslError, | |
| 465 | hstsPreloaded: hstsPreloaded, | |
| 466 | 721 | httpHeaders: httpHeaders, |
| 467 | httpSecurityGrade: httpSecurityGrade, | |
| 468 | httpStatusCode: httpStatusCode, | |
| 469 | httpResponseTimeMs: httpResponseTimeMs, | |
| 470 | httpProtocol: httpProtocol, | |
| 471 | http3Advertised: http3Advertised, | |
| 472 | httpHeadersError: httpHeadersError, | |
| 473 | 722 | reachabilityResults: reachabilityResults, |
| 474 | 723 | ipGeolocation: ipGeolocation, |
| 475 | ipGeolocationError: ipGeolocationError, | |
| 476 | 724 | emailSecurity: emailSecurity, |
| 725 | mtaSts: emailSecurity?.mtaSts, | |
| 477 | 726 | ptrRecord: ptrRecord, |
| 478 | 727 | redirectChain: redirectChain, |
| 479 | portScanResults: portScanResults | |
| 728 | portScanResults: allPortScanResults, | |
| 729 | hstsPreloaded: hstsPreloaded, | |
| 730 | resolverDisplayName: resolverDisplayName, | |
| 731 | resolverURLString: resolverURLString, | |
| 732 | totalLookupDurationMs: lastLookupDurationMs, | |
| 733 | sslError: sslError, | |
| 734 | httpHeadersError: httpHeadersError, | |
| 735 | reachabilityError: reachabilityError, | |
| 736 | ipGeolocationError: ipGeolocationError, | |
| 737 | emailSecurityError: emailSecurityError, | |
| 738 | ptrError: ptrError, | |
| 739 | redirectChainError: redirectChainError, | |
| 740 | portScanError: combinedPortScanError | |
| 480 | 741 | ) |
| 742 | ||
| 743 | if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame { | |
| 744 | history[0] = entry | |
| 745 | } else { | |
| 746 | history.insert(entry, at: 0) | |
| 747 | if history.count > Self.maxHistory { | |
| 748 | history = Array(history.prefix(Self.maxHistory)) | |
| 749 | } | |
| 750 | } | |
| 751 | persistHistory() | |
| 752 | } | |
| 753 | ||
| 754 | private func persistHistory() { | |
| 755 | if let data = try? JSONEncoder().encode(history) { | |
| 756 | UserDefaults.standard.set(data, forKey: Self.historyKey) | |
| 757 | } | |
| 758 | } | |
| 759 | ||
| 760 | private func addRecentSearch(_ domain: String) { | |
| 761 | recentSearches.removeAll { $0.lowercased() == domain.lowercased() } | |
| 762 | recentSearches.insert(domain, at: 0) | |
| 763 | if recentSearches.count > Self.maxRecent { | |
| 764 | recentSearches = Array(recentSearches.prefix(Self.maxRecent)) | |
| 765 | } | |
| 766 | UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) | |
| 767 | } | |
| 768 | ||
| 769 | private func clearLookupState() { | |
| 770 | dnsSections = [] | |
| 771 | dnsError = nil | |
| 772 | dnsLoading = false | |
| 773 | sslInfo = nil | |
| 774 | sslError = nil | |
| 775 | sslLoading = false | |
| 776 | hstsPreloaded = nil | |
| 777 | hstsLoading = false | |
| 778 | httpHeaders = [] | |
| 779 | httpSecurityGrade = nil | |
| 780 | httpStatusCode = nil | |
| 781 | httpResponseTimeMs = nil | |
| 782 | httpProtocol = nil | |
| 783 | http3Advertised = false | |
| 784 | httpHeadersError = nil | |
| 785 | httpHeadersLoading = false | |
| 786 | reachabilityResults = [] | |
| 787 | reachabilityError = nil | |
| 788 | reachabilityLoading = false | |
| 789 | ipGeolocation = nil | |
| 790 | ipGeolocationError = nil | |
| 791 | ipGeolocationLoading = false | |
| 792 | emailSecurity = nil | |
| 793 | emailSecurityError = nil | |
| 794 | emailSecurityLoading = false | |
| 795 | ptrRecord = nil | |
| 796 | ptrError = nil | |
| 797 | ptrLoading = false | |
| 798 | redirectChain = [] | |
| 799 | redirectChainError = nil | |
| 800 | redirectChainLoading = false | |
| 801 | portScanResults = [] | |
| 802 | portScanError = nil | |
| 803 | portScanLoading = false | |
| 804 | customPortResults = [] | |
| 805 | customPortScanError = nil | |
| 806 | customPortScanLoading = false | |
| 807 | } | |
| 808 | ||
| 809 | private func setAllLoadingStates(_ loading: Bool) { | |
| 810 | dnsLoading = loading | |
| 811 | sslLoading = loading | |
| 812 | hstsLoading = loading | |
| 813 | httpHeadersLoading = loading | |
| 814 | reachabilityLoading = loading | |
| 815 | ipGeolocationLoading = loading | |
| 816 | emailSecurityLoading = loading | |
| 817 | ptrLoading = loading | |
| 818 | redirectChainLoading = loading | |
| 819 | portScanLoading = loading | |
| 820 | } | |
| 821 | ||
| 822 | private func primaryIPAddress(from sections: [DNSSection]) -> String? { | |
| 823 | sections.first(where: { $0.recordType == .A })?.records.first?.value | |
| 824 | } | |
| 825 | ||
| 826 | private func isCurrentLookup(_ lookupID: UUID) -> Bool { | |
| 827 | activeLookupID == lookupID | |
| 828 | } | |
| 829 | ||
| 830 | static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { | |
| 831 | [ | |
| 832 | SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), | |
| 833 | SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), | |
| 834 | SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), | |
| 835 | SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), | |
| 836 | SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary) | |
| 837 | ] | |
| 838 | } | |
| 839 | ||
| 840 | static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { | |
| 841 | [ | |
| 842 | InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), | |
| 843 | InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), | |
| 844 | InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), | |
| 845 | InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) | |
| 846 | ] | |
| 847 | } | |
| 848 | ||
| 849 | static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { | |
| 850 | snapshot.dnsSections.map { section in | |
| 851 | DNSRecordSectionViewData( | |
| 852 | title: section.recordType.rawValue, | |
| 853 | rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, | |
| 854 | wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, | |
| 855 | wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)", | |
| 856 | message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ?? | |
| 857 | ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil) | |
| 858 | ) | |
| 859 | } | |
| 860 | } | |
| 861 | ||
| 862 | static func dnssecLabel(from snapshot: LookupSnapshot) -> String? { | |
| 863 | guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil } | |
| 864 | return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")" | |
| 865 | } | |
| 866 | ||
| 867 | static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? { | |
| 868 | if let ptrRecord = snapshot.ptrRecord { | |
| 869 | return SectionMessageViewData(text: ptrRecord, isError: false) | |
| 870 | } | |
| 871 | if let ptrError = snapshot.ptrError { | |
| 872 | return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found") | |
| 873 | } | |
| 874 | return nil | |
| 875 | } | |
| 876 | ||
| 877 | static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { | |
| 878 | guard let sslInfo = snapshot.sslInfo else { return [] } | |
| 879 | var rows = [ | |
| 880 | InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary), | |
| 881 | InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), | |
| 882 | InfoRowViewData(label: "Valid From", value: DateFormatter.certDate.string(from: sslInfo.validFrom), tone: .secondary), | |
| 883 | InfoRowViewData(label: "Valid Until", value: DateFormatter.certDate.string(from: sslInfo.validUntil), tone: .secondary), | |
| 884 | InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)), | |
| 885 | InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) | |
| 886 | ] | |
| 887 | if let tlsVersion = sslInfo.tlsVersion { | |
| 888 | rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary)) | |
| 889 | } | |
| 890 | if let cipherSuite = sslInfo.cipherSuite { | |
| 891 | rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary)) | |
| 892 | } | |
| 893 | if let hstsPreloaded = snapshot.hstsPreloaded { | |
| 894 | rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary)) | |
| 895 | } | |
| 896 | return rows | |
| 897 | } | |
| 898 | ||
| 899 | static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { | |
| 900 | var rows: [InfoRowViewData] = [] | |
| 901 | if let httpStatusCode = snapshot.httpStatusCode { | |
| 902 | rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary)) | |
| 903 | } | |
| 904 | if let httpResponseTimeMs = snapshot.httpResponseTimeMs { | |
| 905 | rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary)) | |
| 906 | } | |
| 907 | if let httpProtocol = snapshot.httpProtocol { | |
| 908 | rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary)) | |
| 909 | } | |
| 910 | if let httpSecurityGrade = snapshot.httpSecurityGrade { | |
| 911 | rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade))) | |
| 912 | } | |
| 913 | if snapshot.http3Advertised { | |
| 914 | rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary)) | |
| 915 | } | |
| 916 | return rows | |
| 917 | } | |
| 918 | ||
| 919 | static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] { | |
| 920 | snapshot.redirectChain.map { | |
| 921 | RedirectHopViewData( | |
| 922 | stepLabel: "\($0.stepNumber)", | |
| 923 | statusCode: "\($0.statusCode)", | |
| 924 | url: $0.url, | |
| 925 | isFinal: $0.isFinal | |
| 926 | ) | |
| 927 | } | |
| 481 | 928 | } |
| 482 | 929 | |
| 483 | static func formatExportText( | |
| 484 | domain: String, | |
| 485 | date: Date, | |
| 486 | dnsSections: [DNSSection], | |
| 487 | sslInfo: SSLCertificateInfo?, | |
| 488 | sslError: String? = nil, | |
| 489 | hstsPreloaded: Bool? = nil, | |
| 490 | httpHeaders: [HTTPHeader], | |
| 491 | httpSecurityGrade: String? = nil, | |
| 492 | httpStatusCode: Int? = nil, | |
| 493 | httpResponseTimeMs: Int? = nil, | |
| 494 | httpProtocol: String? = nil, | |
| 495 | http3Advertised: Bool = false, | |
| 496 | httpHeadersError: String? = nil, | |
| 497 | reachabilityResults: [PortReachability], | |
| 498 | ipGeolocation: IPGeolocation?, | |
| 499 | ipGeolocationError: String? = nil, | |
| 500 | emailSecurity: EmailSecurityResult? = nil, | |
| 501 | ptrRecord: String? = nil, | |
| 502 | redirectChain: [RedirectHop] = [], | |
| 503 | portScanResults: [PortScanResult] = [] | |
| 504 | ) -> String { | |
| 505 | let dateFmt = DateFormatter() | |
| 506 | dateFmt.dateFormat = "yyyy-MM-dd HH:mm" | |
| 930 | static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] { | |
| 931 | guard let emailSecurity = snapshot.emailSecurity else { return [] } | |
| 932 | return [ | |
| 933 | EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil), | |
| 934 | EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil), | |
| 935 | EmailRowViewData(label: "DKIM", status: emailSecurity.dkim.found ? "Present" : "Missing", statusTone: emailSecurity.dkim.found ? .success : .warning, detail: emailSecurity.dkim.value ?? "No record found", auxiliaryDetail: emailSecurity.dkim.matchedSelector.map { "Selector: \($0)" }), | |
| 936 | EmailRowViewData(label: "MTA-STS", status: emailSecurity.mtaSts?.txtFound == true ? "Present" : "Missing", statusTone: emailSecurity.mtaSts?.txtFound == true ? .success : .warning, detail: emailSecurity.mtaSts?.policyMode ?? (emailSecurity.mtaSts?.txtFound == true ? "Policy unavailable" : "No record found"), auxiliaryDetail: nil), | |
| 937 | EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil) | |
| 938 | ] | |
| 939 | } | |
| 940 | ||
| 941 | static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] { | |
| 942 | snapshot.reachabilityResults.map { | |
| 943 | ReachabilityRowViewData( | |
| 944 | portLabel: "Port \($0.port)", | |
| 945 | latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—", | |
| 946 | statusLabel: $0.reachable ? "Reachable" : "Unreachable", | |
| 947 | statusTone: $0.reachable ? .success : .failure | |
| 948 | ) | |
| 949 | } | |
| 950 | } | |
| 951 | ||
| 952 | static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { | |
| 953 | guard let ipGeolocation = snapshot.ipGeolocation else { return [] } | |
| 954 | var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)] | |
| 955 | if let org = ipGeolocation.org { | |
| 956 | rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary)) | |
| 957 | } | |
| 958 | let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 959 | if !location.isEmpty { | |
| 960 | rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary)) | |
| 961 | } | |
| 962 | if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude { | |
| 963 | rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary)) | |
| 964 | } | |
| 965 | return rows | |
| 966 | } | |
| 967 | ||
| 968 | static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] { | |
| 969 | snapshot.portScanResults | |
| 970 | .filter { $0.kind == kind } | |
| 971 | .map { | |
| 972 | PortScanRowViewData( | |
| 973 | portLabel: "\($0.port)", | |
| 974 | service: $0.service, | |
| 975 | statusLabel: $0.open ? "Open" : "Closed", | |
| 976 | statusTone: $0.open ? .success : .secondary, | |
| 977 | banner: $0.banner, | |
| 978 | durationLabel: $0.durationMs.map { "\($0) ms" } | |
| 979 | ) | |
| 980 | } | |
| 981 | } | |
| 982 | ||
| 983 | static func formatExportText(from snapshot: LookupSnapshot) -> String { | |
| 984 | let exportDateFormatter = DateFormatter() | |
| 985 | exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm" | |
| 507 | 986 | |
| 508 | 987 | var lines: [String] = [ |
| 509 | 988 | "DomainDig Export", |
| 510 | "Domain: \(domain)", | |
| 511 | "Date: \(dateFmt.string(from: date))", | |
| 989 | "Domain: \(snapshot.domain)", | |
| 990 | "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", | |
| 991 | "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", | |
| 992 | "Resolver: \(snapshot.resolverDisplayName)", | |
| 993 | "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))" | |
| 512 | 994 | ] |
| 513 | 995 | |
| 514 | // Reachability | |
| 515 | if !reachabilityResults.isEmpty { | |
| 996 | func appendSection(_ title: String, body: () -> Void) { | |
| 516 | 997 | lines.append("") |
| 517 | lines.append("Reachability") | |
| 518 | lines.append("------------") | |
| 519 | for result in reachabilityResults { | |
| 520 | if result.reachable, let ms = result.latencyMs { | |
| 521 | lines.append(" Port \(result.port) \(ms)ms Reachable") | |
| 522 | } else { | |
| 523 | lines.append(" Port \(result.port) — Unreachable") | |
| 524 | } | |
| 998 | lines.append(title) | |
| 999 | lines.append(String(repeating: "-", count: title.count)) | |
| 1000 | body() | |
| 1001 | } | |
| 1002 | ||
| 1003 | appendSection("Summary") { | |
| 1004 | for item in summaryFields(from: snapshot) { | |
| 1005 | lines.append(" \(item.label): \(item.value)") | |
| 525 | 1006 | } |
| 526 | 1007 | } |
| 527 | 1008 | |
| 528 | // Redirect Chain | |
| 529 | if !redirectChain.isEmpty { | |
| 530 | lines.append("") | |
| 531 | lines.append("Redirect Chain") | |
| 532 | lines.append("--------------") | |
| 533 | if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) { | |
| 534 | lines.append(" No redirects — direct connection") | |
| 535 | } else { | |
| 536 | for hop in redirectChain { | |
| 537 | let final = hop.isFinal ? " (final)" : "" | |
| 538 | lines.append(" \(hop.stepNumber) \(hop.statusCode) \(hop.url)\(final)") | |
| 539 | } | |
| 1009 | appendSection("Domain") { | |
| 1010 | for row in domainRows(from: snapshot) { | |
| 1011 | lines.append(" \(row.label): \(row.value)") | |
| 540 | 1012 | } |
| 541 | 1013 | } |
| 542 | 1014 | |
| 543 | // DNS | |
| 544 | lines.append("") | |
| 545 | lines.append("DNS Records") | |
| 546 | lines.append("-----------") | |
| 547 | for section in dnsSections { | |
| 548 | lines.append(section.recordType.rawValue) | |
| 549 | if let error = section.error { | |
| 550 | lines.append(" Error: \(error)") | |
| 551 | } else if section.records.isEmpty { | |
| 552 | lines.append(" No records found") | |
| 553 | } else { | |
| 554 | for record in section.records { | |
| 555 | lines.append(" \(record.value) TTL \(record.ttl)") | |
| 556 | } | |
| 1015 | appendSection("DNS") { | |
| 1016 | if let dnsError = snapshot.dnsError { | |
| 1017 | lines.append(" Error: \(dnsError)") | |
| 1018 | } | |
| 1019 | if let dnssecLabel = dnssecLabel(from: snapshot) { | |
| 1020 | lines.append(" \(dnssecLabel)") | |
| 557 | 1021 | } |
| 558 | if !section.wildcardRecords.isEmpty { | |
| 559 | lines.append("*.\(domain)") | |
| 560 | for record in section.wildcardRecords { | |
| 561 | lines.append(" \(record.value) TTL \(record.ttl)") | |
| 1022 | for section in dnsRows(from: snapshot) { | |
| 1023 | lines.append(" \(section.title)") | |
| 1024 | if let message = section.message { | |
| 1025 | lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)") | |
| 562 | 1026 | } |
| 1027 | for row in section.rows { | |
| 1028 | lines.append(" \(row.value) (\(row.label))") | |
| 1029 | } | |
| 1030 | if let wildcardTitle = section.wildcardTitle { | |
| 1031 | lines.append(" \(wildcardTitle)") | |
| 1032 | for row in section.wildcardRows { | |
| 1033 | lines.append(" \(row.value) (\(row.label))") | |
| 1034 | } | |
| 1035 | } | |
| 1036 | } | |
| 1037 | if let ptrRecord = snapshot.ptrRecord { | |
| 1038 | lines.append(" PTR: \(ptrRecord)") | |
| 1039 | } else if let ptrError = snapshot.ptrError { | |
| 1040 | lines.append(" PTR Error: \(ptrError)") | |
| 563 | 1041 | } |
| 564 | 1042 | } |
| 565 | 1043 | |
| 566 | // PTR | |
| 567 | if let ptr = ptrRecord { | |
| 568 | lines.append("PTR (Reverse DNS)") | |
| 569 | lines.append(" \(ptr)") | |
| 570 | } | |
| 1044 | appendSection("Web") { | |
| 1045 | if let sslError = snapshot.sslError { | |
| 1046 | lines.append(" TLS Error: \(sslError)") | |
| 1047 | } else { | |
| 1048 | for row in webCertificateRows(from: snapshot) { | |
| 1049 | lines.append(" \(row.label): \(row.value)") | |
| 1050 | } | |
| 1051 | } | |
| 571 | 1052 | |
| 572 | // Email Security | |
| 573 | if let email = emailSecurity { | |
| 574 | lines.append("") | |
| 575 | lines.append("Email Security") | |
| 576 | lines.append("--------------") | |
| 577 | lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")") | |
| 578 | lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")") | |
| 579 | let dkimValue = if let selector = email.dkim.matchedSelector, | |
| 580 | let value = email.dkim.value { | |
| 581 | "\(value) (selector: \(selector))" | |
| 1053 | if let httpHeadersError = snapshot.httpHeadersError { | |
| 1054 | lines.append(" Headers Error: \(httpHeadersError)") | |
| 582 | 1055 | } else { |
| 583 | email.dkim.value ?? "No record found" | |
| 1056 | for row in webResponseRows(from: snapshot) { | |
| 1057 | lines.append(" \(row.label): \(row.value)") | |
| 1058 | } | |
| 1059 | if snapshot.httpHeaders.isEmpty { | |
| 1060 | lines.append(" Headers: No headers returned") | |
| 1061 | } else { | |
| 1062 | lines.append(" Headers:") | |
| 1063 | for header in snapshot.httpHeaders { | |
| 1064 | lines.append(" \(header.name): \(header.value)") | |
| 1065 | } | |
| 1066 | } | |
| 584 | 1067 | } |
| 585 | lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)") | |
| 586 | let mtaDescription = if let mode = email.mtaSts?.policyMode { | |
| 587 | "mode: \(mode)" | |
| 588 | } else if email.mtaSts?.txtFound == true { | |
| 589 | "Policy unavailable" | |
| 1068 | ||
| 1069 | if let redirectChainError = snapshot.redirectChainError { | |
| 1070 | lines.append(" Redirect Error: \(redirectChainError)") | |
| 1071 | } else if snapshot.redirectChain.isEmpty { | |
| 1072 | lines.append(" Redirects: No redirect data available") | |
| 590 | 1073 | } else { |
| 591 | "No record found" | |
| 1074 | lines.append(" Redirects:") | |
| 1075 | for hop in redirectRows(from: snapshot) { | |
| 1076 | lines.append(" \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")") | |
| 1077 | } | |
| 592 | 1078 | } |
| 593 | lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)") | |
| 594 | lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")") | |
| 595 | 1079 | } |
| 596 | 1080 | |
| 597 | // SSL | |
| 598 | if let info = sslInfo { | |
| 599 | let certDateFmt = DateFormatter() | |
| 600 | certDateFmt.dateStyle = .medium | |
| 601 | certDateFmt.timeStyle = .none | |
| 602 | ||
| 603 | lines.append("") | |
| 604 | lines.append("SSL / TLS Certificate") | |
| 605 | lines.append("---------------------") | |
| 606 | lines.append("Common Name: \(info.commonName)") | |
| 607 | lines.append("Issuer: \(info.issuer)") | |
| 608 | lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))") | |
| 609 | lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))") | |
| 610 | lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))") | |
| 611 | lines.append("Days Until Expiry: \(info.daysUntilExpiry)") | |
| 612 | lines.append("Chain Depth: \(info.chainDepth)") | |
| 613 | if let tlsVersion = info.tlsVersion { | |
| 614 | lines.append("TLS Version: \(tlsVersion)") | |
| 615 | } | |
| 616 | if let cipherSuite = info.cipherSuite { | |
| 617 | lines.append("Cipher Suite: \(cipherSuite)") | |
| 618 | } | |
| 619 | if let hstsPreloaded { | |
| 620 | lines.append("HSTS Preload: \(hstsPreloaded ? "Preloaded" : "Not preloaded")") | |
| 621 | } | |
| 622 | if !info.chain.isEmpty { | |
| 623 | lines.append("Certificate Chain:") | |
| 624 | for certificate in info.chain { | |
| 625 | lines.append(" Subject: \(certificate.subject)") | |
| 626 | lines.append(" Issuer: \(certificate.issuer)") | |
| 1081 | appendSection("Email") { | |
| 1082 | if let emailSecurityError = snapshot.emailSecurityError { | |
| 1083 | lines.append(" Error: \(emailSecurityError)") | |
| 1084 | } else if emailRows(from: snapshot).isEmpty { | |
| 1085 | lines.append(" No email security records found") | |
| 1086 | } else { | |
| 1087 | for row in emailRows(from: snapshot) { | |
| 1088 | lines.append(" \(row.label): \(row.status)") | |
| 1089 | lines.append(" \(row.detail)") | |
| 1090 | if let auxiliaryDetail = row.auxiliaryDetail { | |
| 1091 | lines.append(" \(auxiliaryDetail)") | |
| 1092 | } | |
| 627 | 1093 | } |
| 628 | 1094 | } |
| 629 | } else if let error = sslError { | |
| 630 | lines.append("") | |
| 631 | lines.append("SSL / TLS Certificate") | |
| 632 | lines.append("---------------------") | |
| 633 | lines.append("Error: \(error)") | |
| 634 | 1095 | } |
| 635 | 1096 | |
| 636 | // HTTP Headers | |
| 637 | if !httpHeaders.isEmpty { | |
| 638 | lines.append("") | |
| 639 | lines.append("HTTP Headers") | |
| 640 | lines.append("------------") | |
| 641 | for header in httpHeaders { | |
| 642 | lines.append(" \(header.name): \(header.value)") | |
| 643 | } | |
| 644 | if let httpSecurityGrade { | |
| 645 | lines.append("Grade: \(httpSecurityGrade)") | |
| 646 | } | |
| 647 | if let httpStatusCode { | |
| 648 | lines.append("Status: \(httpStatusCode)") | |
| 649 | } | |
| 650 | if let httpResponseTimeMs { | |
| 651 | lines.append("Response Time: \(httpResponseTimeMs)ms") | |
| 652 | } | |
| 653 | if let httpProtocol { | |
| 654 | lines.append("Protocol: \(httpProtocol)") | |
| 1097 | appendSection("Network") { | |
| 1098 | if let reachabilityError = snapshot.reachabilityError { | |
| 1099 | lines.append(" Reachability Error: \(reachabilityError)") | |
| 1100 | } else if reachabilityRows(from: snapshot).isEmpty { | |
| 1101 | lines.append(" Reachability: No results") | |
| 1102 | } else { | |
| 1103 | lines.append(" Reachability:") | |
| 1104 | for row in reachabilityRows(from: snapshot) { | |
| 1105 | lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)") | |
| 1106 | } | |
| 655 | 1107 | } |
| 656 | if http3Advertised { | |
| 657 | lines.append("HTTP/3 Advertised: Yes") | |
| 1108 | ||
| 1109 | if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil { | |
| 1110 | lines.append(" Location Error: \(ipGeolocationError)") | |
| 1111 | } else if locationRows(from: snapshot).isEmpty { | |
| 1112 | lines.append(" Location: No data") | |
| 1113 | } else { | |
| 1114 | lines.append(" Location:") | |
| 1115 | for row in locationRows(from: snapshot) { | |
| 1116 | lines.append(" \(row.label): \(row.value)") | |
| 1117 | } | |
| 658 | 1118 | } |
| 659 | } else if let error = httpHeadersError { | |
| 660 | lines.append("") | |
| 661 | lines.append("HTTP Headers") | |
| 662 | lines.append("------------") | |
| 663 | lines.append("Error: \(error)") | |
| 664 | } | |
| 665 | 1119 | |
| 666 | // IP Geolocation | |
| 667 | if let geo = ipGeolocation { | |
| 668 | lines.append("") | |
| 669 | lines.append("IP Location") | |
| 670 | lines.append("-----------") | |
| 671 | lines.append("IP: \(geo.ip)") | |
| 672 | if let org = geo.org { lines.append("Org: \(org)") } | |
| 673 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 674 | if !location.isEmpty { lines.append("Location: \(location)") } | |
| 675 | if let lat = geo.latitude, let lon = geo.longitude { | |
| 676 | lines.append("Coordinates: \(lat), \(lon)") | |
| 1120 | if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty { | |
| 1121 | lines.append(" Port Scan Error: \(portScanError)") | |
| 677 | 1122 | } |
| 678 | } else if let error = ipGeolocationError, error != "No A record available" { | |
| 679 | lines.append("") | |
| 680 | lines.append("IP Location") | |
| 681 | lines.append("-----------") | |
| 682 | lines.append("Error: \(error)") | |
| 683 | } | |
| 684 | 1123 | |
| 685 | // Open Ports | |
| 686 | if !portScanResults.isEmpty { | |
| 687 | lines.append("") | |
| 688 | lines.append("Open Ports") | |
| 689 | lines.append("----------") | |
| 690 | let openPorts = portScanResults.filter { $0.open } | |
| 691 | if openPorts.isEmpty { | |
| 692 | lines.append(" No open ports detected") | |
| 1124 | lines.append(" Standard Ports:") | |
| 1125 | let standardRows = portRows(from: snapshot, kind: .standard) | |
| 1126 | if standardRows.isEmpty { | |
| 1127 | lines.append(" No results") | |
| 693 | 1128 | } else { |
| 694 | for port in openPorts { | |
| 695 | let bannerSuffix = port.banner.map { " \($0)" } ?? "" | |
| 696 | lines.append(" \(port.port) \(port.service)\(bannerSuffix)") | |
| 1129 | for row in standardRows { | |
| 1130 | lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")") | |
| 1131 | if let banner = row.banner { | |
| 1132 | lines.append(" Banner: \(banner)") | |
| 1133 | } | |
| 697 | 1134 | } |
| 698 | 1135 | } |
| 699 | let closedPorts = portScanResults.filter { !$0.open } | |
| 700 | if !closedPorts.isEmpty { | |
| 701 | lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))") | |
| 1136 | ||
| 1137 | lines.append(" Custom Ports:") | |
| 1138 | let customRows = portRows(from: snapshot, kind: .custom) | |
| 1139 | if customRows.isEmpty { | |
| 1140 | lines.append(" No results") | |
| 1141 | } else { | |
| 1142 | for row in customRows { | |
| 1143 | lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")") | |
| 1144 | if let banner = row.banner { | |
| 1145 | lines.append(" Banner: \(banner)") | |
| 1146 | } | |
| 1147 | } | |
| 702 | 1148 | } |
| 703 | 1149 | } |
| 704 | 1150 | |
| 705 | 1151 | return lines.joined(separator: "\n") |
| 706 | 1152 | } |
| 707 | 1153 | |
| 708 | // MARK: - Recent Searches | |
| 1154 | private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { | |
| 1155 | snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value | |
| 1156 | } | |
| 709 | 1157 | |
| 710 | private func addRecentSearch(_ domain: String) { | |
| 711 | recentSearches.removeAll { $0.lowercased() == domain.lowercased() } | |
| 712 | recentSearches.insert(domain, at: 0) | |
| 713 | if recentSearches.count > Self.maxRecent { | |
| 714 | recentSearches = Array(recentSearches.prefix(Self.maxRecent)) | |
| 1158 | private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? { | |
| 1159 | snapshot.redirectChain.last?.url | |
| 1160 | } | |
| 1161 | ||
| 1162 | private static func httpsSummary(from snapshot: LookupSnapshot) -> String { | |
| 1163 | if snapshot.sslInfo != nil { | |
| 1164 | return "Valid" | |
| 715 | 1165 | } |
| 716 | UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) | |
| 1166 | if let sslError = snapshot.sslError { | |
| 1167 | return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed" | |
| 1168 | } | |
| 1169 | return "Unavailable" | |
| 717 | 1170 | } |
| 718 | 1171 | |
| 719 | func clearRecentSearches() { | |
| 720 | recentSearches.removeAll() | |
| 721 | UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) | |
| 1172 | private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone { | |
| 1173 | if snapshot.sslInfo != nil { | |
| 1174 | return .success | |
| 1175 | } | |
| 1176 | return snapshot.sslError == nil ? .secondary : .failure | |
| 1177 | } | |
| 1178 | ||
| 1179 | private static func emailSummary(from snapshot: LookupSnapshot) -> String { | |
| 1180 | guard let emailSecurity = snapshot.emailSecurity else { | |
| 1181 | return snapshot.emailSecurityError ?? "Unavailable" | |
| 1182 | } | |
| 1183 | return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")" | |
| 1184 | } | |
| 1185 | ||
| 1186 | private static func securityGradeTone(_ grade: String) -> ResultTone { | |
| 1187 | switch grade { | |
| 1188 | case "A", "B": | |
| 1189 | return .success | |
| 1190 | case "C": | |
| 1191 | return .warning | |
| 1192 | case "D", "F": | |
| 1193 | return .failure | |
| 1194 | default: | |
| 1195 | return .secondary | |
| 1196 | } | |
| 1197 | } | |
| 1198 | ||
| 1199 | private static func durationLabel(_ durationMs: Int?) -> String { | |
| 1200 | durationMs.map { "\($0) ms" } ?? "Unavailable" | |
| 1201 | } | |
| 1202 | } | |
| 1203 | ||
| 1204 | private extension String { | |
| 1205 | var nonEmpty: String? { | |
| 1206 | isEmpty ? nil : self | |
| 1207 | } | |
| 1208 | ||
| 1209 | var nilIfEmpty: String? { | |
| 1210 | isEmpty ? nil : self | |
| 722 | 1211 | } |
| 723 | 1212 | } |
DomainDig/EmailSecurityService.swift +5 −2
| @@ -8,7 +8,7 @@ struct EmailSecurityService { | ||
| 8 | 8 | |
| 9 | 9 | /// Analyze email security records. SPF is parsed from existing TXT records; |
| 10 | 10 | /// DMARC and DKIM require additional DoH queries. |
| 11 | static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { | |
| 11 | static func analyze(domain: String, txtRecords: [DNSRecord]) async -> ServiceResult<EmailSecurityResult> { | |
| 12 | 12 | // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup |
| 13 | 13 | // in case the earlier DNS section missed or normalized the record differently. |
| 14 | 14 | let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value |
| @@ -49,13 +49,16 @@ struct EmailSecurityService { | ||
| 49 | 49 | value: bimiValue |
| 50 | 50 | ) |
| 51 | 51 | |
| 52 | return EmailSecurityResult( | |
| 52 | let result = EmailSecurityResult( | |
| 53 | 53 | spf: spf, |
| 54 | 54 | dmarc: dmarc, |
| 55 | 55 | dkim: dkim, |
| 56 | 56 | bimi: bimi, |
| 57 | 57 | mtaSts: mtaSts |
| 58 | 58 | ) |
| 59 | ||
| 60 | let hasAnyRecord = result.spf.found || result.dmarc.found || result.dkim.found || result.bimi.found || result.mtaSts?.txtFound == true | |
| 61 | return hasAnyRecord ? .success(result) : .empty("No email security records found") | |
| 59 | 62 | } |
| 60 | 63 | |
| 61 | 64 | /// Query a TXT record for the given subdomain via DoH. |
DomainDig/HTTPHeadersService.swift +33 −28
| @@ -35,41 +35,46 @@ struct HTTPHeadersResult { | ||
| 35 | 35 | } |
| 36 | 36 | |
| 37 | 37 | struct HTTPHeadersService { |
| 38 | static func fetch(domain: String) async throws -> HTTPHeadersResult { | |
| 38 | static func fetch(domain: String) async -> ServiceResult<HTTPHeadersResult> { | |
| 39 | 39 | let url = URL(string: "https://\(domain)")! |
| 40 | 40 | var request = URLRequest(url: url, timeoutInterval: 10) |
| 41 | 41 | request.httpMethod = "HEAD" |
| 42 | 42 | let metricsDelegate = TaskMetricsDelegate() |
| 43 | 43 | let startTime = Date() |
| 44 | 44 | |
| 45 | let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate) | |
| 46 | let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000)) | |
| 47 | ||
| 48 | guard let httpResponse = response as? HTTPURLResponse else { | |
| 49 | throw URLError(.badServerResponse) | |
| 50 | } | |
| 51 | ||
| 52 | let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in | |
| 53 | guard let name = entry.key as? String, | |
| 54 | let value = entry.value as? String else { return nil } | |
| 55 | return HTTPHeader(name: name, value: value) | |
| 45 | do { | |
| 46 | let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate) | |
| 47 | let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000)) | |
| 48 | ||
| 49 | guard let httpResponse = response as? HTTPURLResponse else { | |
| 50 | return .error(URLError(.badServerResponse).localizedDescription) | |
| 51 | } | |
| 52 | ||
| 53 | let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in | |
| 54 | guard let name = entry.key as? String, | |
| 55 | let value = entry.value as? String else { return nil } | |
| 56 | return HTTPHeader(name: name, value: value) | |
| 57 | } | |
| 58 | .sorted { $0.name.lowercased() < $1.name.lowercased() } | |
| 59 | ||
| 60 | let networkProtocolName = metricsDelegate.metrics?.transactionMetrics | |
| 61 | .compactMap { $0.networkProtocolName } | |
| 62 | .last | |
| 63 | let detectedProtocol = protocolLabel(for: networkProtocolName) | |
| 64 | let altSvcValue = headerValue(named: "alt-svc", in: httpResponse) | |
| 65 | let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true | |
| 66 | let result = HTTPHeadersResult( | |
| 67 | headers: headers, | |
| 68 | statusCode: httpResponse.statusCode, | |
| 69 | responseTimeMs: responseTimeMs, | |
| 70 | httpProtocol: detectedProtocol, | |
| 71 | http3Advertised: http3Advertised | |
| 72 | ) | |
| 73 | ||
| 74 | return headers.isEmpty ? .empty("No HTTP headers returned") : .success(result) | |
| 75 | } catch { | |
| 76 | return .error(error.localizedDescription) | |
| 56 | 77 | } |
| 57 | .sorted { $0.name.lowercased() < $1.name.lowercased() } | |
| 58 | ||
| 59 | let networkProtocolName = metricsDelegate.metrics?.transactionMetrics | |
| 60 | .compactMap { $0.networkProtocolName } | |
| 61 | .last | |
| 62 | let detectedProtocol = protocolLabel(for: networkProtocolName) | |
| 63 | let altSvcValue = headerValue(named: "alt-svc", in: httpResponse) | |
| 64 | let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true | |
| 65 | ||
| 66 | return HTTPHeadersResult( | |
| 67 | headers: headers, | |
| 68 | statusCode: httpResponse.statusCode, | |
| 69 | responseTimeMs: responseTimeMs, | |
| 70 | httpProtocol: detectedProtocol, | |
| 71 | http3Advertised: http3Advertised | |
| 72 | ) | |
| 73 | 78 | } |
| 74 | 79 | |
| 75 | 80 | private static func protocolLabel(for networkProtocolName: String?) -> String? { |
DomainDig/HistoryView.swift +93 −469
| @@ -1,15 +1,13 @@ | ||
| 1 | 1 | import SwiftUI |
| 2 | import MapKit | |
| 3 | 2 | |
| 4 | 3 | struct HistoryView: View { |
| 5 | 4 | @Bindable var viewModel: DomainViewModel |
| 6 | @Environment(\.dismiss) private var dismiss | |
| 7 | 5 | |
| 8 | private let dateFmt: DateFormatter = { | |
| 9 | let f = DateFormatter() | |
| 10 | f.dateStyle = .medium | |
| 11 | f.timeStyle = .short | |
| 12 | return f | |
| 6 | private let dateFormatter: DateFormatter = { | |
| 7 | let formatter = DateFormatter() | |
| 8 | formatter.dateStyle = .medium | |
| 9 | formatter.timeStyle = .short | |
| 10 | return formatter | |
| 13 | 11 | }() |
| 14 | 12 | |
| 15 | 13 | var body: some View { |
| @@ -22,15 +20,22 @@ struct HistoryView: View { | ||
| 22 | 20 | } else { |
| 23 | 21 | ForEach(viewModel.history) { entry in |
| 24 | 22 | NavigationLink { |
| 25 | HistoryDetailView(entry: entry) | |
| 23 | HistoryDetailView(viewModel: viewModel, entry: entry) | |
| 26 | 24 | } label: { |
| 27 | VStack(alignment: .leading, spacing: 2) { | |
| 25 | VStack(alignment: .leading, spacing: 4) { | |
| 28 | 26 | Text(entry.domain) |
| 29 | 27 | .font(.system(.callout, design: .monospaced)) |
| 30 | 28 | .foregroundStyle(.primary) |
| 31 | Text(dateFmt.string(from: entry.timestamp)) | |
| 32 | .font(.system(.caption2, design: .monospaced)) | |
| 33 | .foregroundStyle(.secondary) | |
| 29 | HStack(spacing: 8) { | |
| 30 | Text(dateFormatter.string(from: entry.timestamp)) | |
| 31 | Text("Snapshot") | |
| 32 | Text(entry.resolverDisplayName) | |
| 33 | if let totalLookupDurationMs = entry.totalLookupDurationMs { | |
| 34 | Text("\(totalLookupDurationMs) ms") | |
| 35 | } | |
| 36 | } | |
| 37 | .font(.system(.caption2, design: .monospaced)) | |
| 38 | .foregroundStyle(.secondary) | |
| 34 | 39 | } |
| 35 | 40 | } |
| 36 | 41 | .listRowBackground(Color(.systemGray6).opacity(0.5)) |
| @@ -52,47 +57,102 @@ struct HistoryView: View { | ||
| 52 | 57 | } |
| 53 | 58 | } |
| 54 | 59 | |
| 55 | // MARK: - History Detail View (Read-Only Cached Results) | |
| 56 | ||
| 57 | 60 | struct HistoryDetailView: View { |
| 61 | @Bindable var viewModel: DomainViewModel | |
| 58 | 62 | let entry: HistoryEntry |
| 59 | 63 | |
| 60 | private let dateFmt: DateFormatter = { | |
| 61 | let f = DateFormatter() | |
| 62 | f.dateStyle = .medium | |
| 63 | f.timeStyle = .short | |
| 64 | return f | |
| 64 | private let dateFormatter: DateFormatter = { | |
| 65 | let formatter = DateFormatter() | |
| 66 | formatter.dateStyle = .medium | |
| 67 | formatter.timeStyle = .short | |
| 68 | return formatter | |
| 65 | 69 | }() |
| 66 | 70 | |
| 71 | private var snapshot: LookupSnapshot { | |
| 72 | entry.snapshot | |
| 73 | } | |
| 74 | ||
| 67 | 75 | var body: some View { |
| 68 | 76 | ScrollView(.vertical) { |
| 69 | 77 | VStack(alignment: .leading, spacing: 0) { |
| 70 | cachedBanner | |
| 71 | reachabilitySection | |
| 72 | redirectChainSection | |
| 73 | dnsSection | |
| 74 | emailSecuritySection | |
| 75 | sslSection | |
| 76 | httpHeadersSection | |
| 77 | ipGeolocationSection | |
| 78 | portScanSection | |
| 78 | snapshotBanner | |
| 79 | SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) | |
| 80 | .padding(.top, 8) | |
| 81 | DomainSectionView(rows: DomainViewModel.domainRows(from: snapshot)) | |
| 82 | .padding(.top, 16) | |
| 83 | DNSSectionView( | |
| 84 | dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), | |
| 85 | sections: DomainViewModel.dnsRows(from: snapshot), | |
| 86 | ptrMessage: DomainViewModel.ptrMessage(from: snapshot), | |
| 87 | loading: false, | |
| 88 | sectionError: snapshot.dnsError | |
| 89 | ) | |
| 90 | .padding(.top, 16) | |
| 91 | WebSectionView( | |
| 92 | certificateRows: DomainViewModel.webCertificateRows(from: snapshot), | |
| 93 | sslInfo: snapshot.sslInfo, | |
| 94 | sslLoading: false, | |
| 95 | sslError: snapshot.sslError, | |
| 96 | responseRows: DomainViewModel.webResponseRows(from: snapshot), | |
| 97 | headers: snapshot.httpHeaders, | |
| 98 | headersLoading: false, | |
| 99 | headersError: snapshot.httpHeadersError, | |
| 100 | redirects: DomainViewModel.redirectRows(from: snapshot), | |
| 101 | redirectLoading: false, | |
| 102 | redirectError: snapshot.redirectChainError, | |
| 103 | finalURL: snapshot.redirectChain.last?.url | |
| 104 | ) | |
| 105 | .padding(.top, 16) | |
| 106 | EmailSectionView( | |
| 107 | rows: DomainViewModel.emailRows(from: snapshot), | |
| 108 | loading: false, | |
| 109 | error: snapshot.emailSecurityError | |
| 110 | ) | |
| 111 | .padding(.top, 16) | |
| 112 | NetworkSectionView( | |
| 113 | reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), | |
| 114 | reachabilityLoading: false, | |
| 115 | reachabilityError: snapshot.reachabilityError, | |
| 116 | locationRows: DomainViewModel.locationRows(from: snapshot), | |
| 117 | geolocation: snapshot.ipGeolocation, | |
| 118 | geolocationLoading: false, | |
| 119 | geolocationError: snapshot.ipGeolocationError, | |
| 120 | standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard), | |
| 121 | customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom), | |
| 122 | portScanLoading: false, | |
| 123 | portScanError: snapshot.portScanError, | |
| 124 | customPortScanLoading: false, | |
| 125 | customPortScanError: nil, | |
| 126 | isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }), | |
| 127 | customPortsExpanded: .constant(false), | |
| 128 | customPortInput: .constant(""), | |
| 129 | onScanCustomPorts: {} | |
| 130 | ) | |
| 131 | .padding(.top, 16) | |
| 79 | 132 | } |
| 80 | 133 | .padding(.horizontal) |
| 81 | 134 | .padding(.bottom, 32) |
| 82 | 135 | } |
| 83 | 136 | .background(Color.black) |
| 84 | 137 | .navigationTitle(entry.domain) |
| 138 | .toolbar { | |
| 139 | Button("Re-run") { | |
| 140 | viewModel.rerunLookup(from: entry) | |
| 141 | } | |
| 142 | } | |
| 85 | 143 | .preferredColorScheme(.dark) |
| 86 | 144 | } |
| 87 | 145 | |
| 88 | // MARK: - Cached Banner | |
| 89 | ||
| 90 | private var cachedBanner: some View { | |
| 91 | HStack(spacing: 6) { | |
| 146 | private var snapshotBanner: some View { | |
| 147 | HStack(spacing: 8) { | |
| 92 | 148 | Image(systemName: "archivebox") |
| 93 | 149 | .font(.caption) |
| 94 | Text("Cached result from \(dateFmt.string(from: entry.timestamp))") | |
| 150 | Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") | |
| 95 | 151 | .font(.system(.caption, design: .monospaced)) |
| 152 | Spacer() | |
| 153 | Text("Live re-run available") | |
| 154 | .font(.system(.caption2, design: .monospaced)) | |
| 155 | .foregroundStyle(.secondary) | |
| 96 | 156 | } |
| 97 | 157 | .foregroundStyle(.secondary) |
| 98 | 158 | .padding(8) |
| @@ -101,440 +161,4 @@ struct HistoryDetailView: View { | ||
| 101 | 161 | .cornerRadius(6) |
| 102 | 162 | .padding(.vertical, 12) |
| 103 | 163 | } |
| 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 | horizontallyScrollableCard { | |
| 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 | } | |
| 184 | } | |
| 185 | } | |
| 186 | .padding(.top, 16) | |
| 187 | } | |
| 188 | ||
| 189 | // MARK: - DNS | |
| 190 | ||
| 191 | private var dnsSection: some View { | |
| 192 | VStack(alignment: .leading, spacing: 12) { | |
| 193 | sectionHeader("DNS Records") | |
| 194 | ForEach(entry.dnsSections) { section in | |
| 195 | horizontallyScrollableCard { | |
| 196 | Text(section.recordType.rawValue) | |
| 197 | .font(.system(.subheadline, design: .monospaced)) | |
| 198 | .fontWeight(.semibold) | |
| 199 | .foregroundStyle(.cyan) | |
| 200 | ||
| 201 | if let error = section.error { | |
| 202 | errorLabel(error) | |
| 203 | } else if section.records.isEmpty { | |
| 204 | Text("No records found") | |
| 205 | .font(.system(.caption, design: .monospaced)) | |
| 206 | .foregroundStyle(.secondary) | |
| 207 | } else { | |
| 208 | recordRows(section.records) | |
| 209 | } | |
| 210 | ||
| 211 | if !section.wildcardRecords.isEmpty { | |
| 212 | Text("*.\(entry.domain)") | |
| 213 | .font(.system(.caption, design: .monospaced)) | |
| 214 | .fontWeight(.medium) | |
| 215 | .foregroundStyle(.cyan.opacity(0.7)) | |
| 216 | .padding(.top, 4) | |
| 217 | recordRows(section.wildcardRecords) | |
| 218 | } | |
| 219 | } | |
| 220 | ||
| 221 | if section.recordType == .A { | |
| 222 | horizontallyScrollableCard { | |
| 223 | Text("PTR (Reverse DNS)") | |
| 224 | .font(.system(.subheadline, design: .monospaced)) | |
| 225 | .fontWeight(.semibold) | |
| 226 | .foregroundStyle(.cyan) | |
| 227 | ||
| 228 | if let ptr = entry.ptrRecord { | |
| 229 | Text(ptr) | |
| 230 | .font(.system(.caption, design: .monospaced)) | |
| 231 | .foregroundStyle(.primary) | |
| 232 | .textSelection(.enabled) | |
| 233 | } else { | |
| 234 | Text("No PTR record found") | |
| 235 | .font(.system(.caption, design: .monospaced)) | |
| 236 | .foregroundStyle(.secondary) | |
| 237 | } | |
| 238 | } | |
| 239 | } | |
| 240 | } | |
| 241 | } | |
| 242 | .padding(.top, 16) | |
| 243 | } | |
| 244 | ||
| 245 | // MARK: - Email Security | |
| 246 | ||
| 247 | @State private var expandedEmailField: String? | |
| 248 | ||
| 249 | private var emailSecuritySection: some View { | |
| 250 | VStack(alignment: .leading, spacing: 12) { | |
| 251 | if let email = entry.emailSecurity { | |
| 252 | sectionHeader("Email Security") | |
| 253 | horizontallyScrollableCard(spacing: 6) { | |
| 254 | historyEmailRow("SPF", record: email.spf) | |
| 255 | historyEmailRow("DMARC", record: email.dmarc) | |
| 256 | historyEmailRow("DKIM", record: email.dkim) | |
| 257 | historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts) | |
| 258 | historyEmailRow("BIMI", record: email.bimi) | |
| 259 | } | |
| 260 | } | |
| 261 | } | |
| 262 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 263 | .padding(.top, 16) | |
| 264 | } | |
| 265 | ||
| 266 | private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View { | |
| 267 | VStack(alignment: .leading, spacing: 2) { | |
| 268 | HStack(spacing: 8) { | |
| 269 | Text(label) | |
| 270 | .font(.system(.caption, design: .monospaced)) | |
| 271 | .fontWeight(.semibold) | |
| 272 | .frame(width: 72, alignment: .leading) | |
| 273 | Text(record.found ? "✓" : "✗") | |
| 274 | .font(.system(.caption, design: .monospaced)) | |
| 275 | .foregroundStyle(record.found ? .green : .red) | |
| 276 | if let value = record.value { | |
| 277 | let isExpanded = expandedEmailField == label | |
| 278 | let displayValue = isExpanded ? value : String(value.prefix(80)) | |
| 279 | Text(displayValue) | |
| 280 | .font(.system(.caption2, design: .monospaced)) | |
| 281 | .foregroundStyle(.primary) | |
| 282 | .textSelection(.enabled) | |
| 283 | .lineLimit(isExpanded ? nil : 1) | |
| 284 | .onTapGesture { | |
| 285 | withAnimation { | |
| 286 | expandedEmailField = isExpanded ? nil : label | |
| 287 | } | |
| 288 | } | |
| 289 | if let selector = record.matchedSelector { | |
| 290 | Text("(selector: \(selector))") | |
| 291 | .font(.system(.caption2, design: .monospaced)) | |
| 292 | .foregroundStyle(.secondary) | |
| 293 | } | |
| 294 | } else { | |
| 295 | Text("No record found") | |
| 296 | .font(.system(.caption2, design: .monospaced)) | |
| 297 | .foregroundStyle(.secondary) | |
| 298 | } | |
| 299 | } | |
| 300 | } | |
| 301 | } | |
| 302 | ||
| 303 | private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View { | |
| 304 | VStack(alignment: .leading, spacing: 2) { | |
| 305 | HStack(spacing: 8) { | |
| 306 | Text(label) | |
| 307 | .font(.system(.caption, design: .monospaced)) | |
| 308 | .fontWeight(.semibold) | |
| 309 | .frame(width: 72, alignment: .leading) | |
| 310 | Text(mtaSts?.txtFound == true ? "✓" : "✗") | |
| 311 | .font(.system(.caption, design: .monospaced)) | |
| 312 | .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) | |
| 313 | if let policyMode = mtaSts?.policyMode { | |
| 314 | Text(policyMode) | |
| 315 | .font(.system(.caption2, design: .monospaced)) | |
| 316 | .foregroundStyle(.primary) | |
| 317 | .textSelection(.enabled) | |
| 318 | } else { | |
| 319 | Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") | |
| 320 | .font(.system(.caption2, design: .monospaced)) | |
| 321 | .foregroundStyle(.secondary) | |
| 322 | } | |
| 323 | } | |
| 324 | } | |
| 325 | } | |
| 326 | ||
| 327 | // MARK: - SSL | |
| 328 | ||
| 329 | private var sslSection: some View { | |
| 330 | VStack(alignment: .leading, spacing: 12) { | |
| 331 | if let info = entry.sslInfo { | |
| 332 | sectionHeader("SSL / TLS Certificate") | |
| 333 | horizontallyScrollableCard(spacing: 8) { | |
| 334 | labelRow("Common Name", info.commonName) | |
| 335 | labelRow("Issuer", info.issuer) | |
| 336 | ||
| 337 | VStack(alignment: .leading, spacing: 2) { | |
| 338 | Text("SANs") | |
| 339 | .font(.system(.caption2, design: .monospaced)) | |
| 340 | .foregroundStyle(.secondary) | |
| 341 | ForEach(info.subjectAltNames, id: \.self) { san in | |
| 342 | Text(san) | |
| 343 | .font(.system(.caption, design: .monospaced)) | |
| 344 | .textSelection(.enabled) | |
| 345 | } | |
| 346 | } | |
| 347 | ||
| 348 | labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom)) | |
| 349 | labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil)) | |
| 350 | ||
| 351 | HStack { | |
| 352 | Text("Days Until Expiry") | |
| 353 | .font(.system(.caption2, design: .monospaced)) | |
| 354 | .foregroundStyle(.secondary) | |
| 355 | Spacer() | |
| 356 | Text("\(info.daysUntilExpiry)") | |
| 357 | .font(.system(.caption, design: .monospaced)) | |
| 358 | .fontWeight(.bold) | |
| 359 | .foregroundStyle(expiryColor(info.daysUntilExpiry)) | |
| 360 | } | |
| 361 | ||
| 362 | labelRow("Chain Depth", "\(info.chainDepth)") | |
| 363 | } | |
| 364 | } | |
| 365 | } | |
| 366 | .padding(.top, 16) | |
| 367 | } | |
| 368 | ||
| 369 | // MARK: - HTTP Headers | |
| 370 | ||
| 371 | private var httpHeadersSection: some View { | |
| 372 | VStack(alignment: .leading, spacing: 12) { | |
| 373 | if !entry.httpHeaders.isEmpty { | |
| 374 | sectionHeader("HTTP Headers") | |
| 375 | horizontallyScrollableCard { | |
| 376 | ForEach(entry.httpHeaders) { header in | |
| 377 | HStack(alignment: .top, spacing: 4) { | |
| 378 | Text(header.name + ":") | |
| 379 | .font(.system(.caption, design: .monospaced)) | |
| 380 | .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) | |
| 381 | Text(header.value) | |
| 382 | .font(.system(.caption, design: .monospaced)) | |
| 383 | .foregroundStyle(.primary) | |
| 384 | .textSelection(.enabled) | |
| 385 | } | |
| 386 | } | |
| 387 | } | |
| 388 | } | |
| 389 | } | |
| 390 | .padding(.top, 16) | |
| 391 | } | |
| 392 | ||
| 393 | // MARK: - IP Geolocation | |
| 394 | ||
| 395 | private var ipGeolocationSection: some View { | |
| 396 | VStack(alignment: .leading, spacing: 12) { | |
| 397 | if let geo = entry.ipGeolocation { | |
| 398 | sectionHeader("IP Location") | |
| 399 | VStack(alignment: .leading, spacing: 6) { | |
| 400 | horizontallyScrollableContent(spacing: 6) { | |
| 401 | labelRow("IP", geo.ip) | |
| 402 | if let org = geo.org { | |
| 403 | labelRow("Org / ISP", org) | |
| 404 | } | |
| 405 | let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") | |
| 406 | if !location.isEmpty { | |
| 407 | labelRow("Location", location) | |
| 408 | } | |
| 409 | } | |
| 410 | ||
| 411 | if let lat = geo.latitude, let lon = geo.longitude { | |
| 412 | let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) | |
| 413 | Map(initialPosition: .region(MKCoordinateRegion( | |
| 414 | center: coordinate, | |
| 415 | span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) | |
| 416 | ))) { | |
| 417 | Marker(geo.ip, coordinate: coordinate) | |
| 418 | } | |
| 419 | .mapStyle(.standard) | |
| 420 | .frame(maxWidth: .infinity) | |
| 421 | .frame(height: 180) | |
| 422 | .cornerRadius(8) | |
| 423 | } | |
| 424 | } | |
| 425 | .padding(10) | |
| 426 | .background(Color(.systemGray6).opacity(0.5)) | |
| 427 | .cornerRadius(6) | |
| 428 | } | |
| 429 | } | |
| 430 | .padding(.top, 16) | |
| 431 | } | |
| 432 | ||
| 433 | // MARK: - Port Scan | |
| 434 | ||
| 435 | private var portScanSection: some View { | |
| 436 | VStack(alignment: .leading, spacing: 12) { | |
| 437 | if !entry.portScanResults.isEmpty { | |
| 438 | sectionHeader("Open Ports") | |
| 439 | VStack(alignment: .leading, spacing: 4) { | |
| 440 | ForEach(entry.portScanResults) { result in | |
| 441 | HStack(spacing: 8) { | |
| 442 | Circle() | |
| 443 | .fill(result.open ? Color.green : Color(.systemGray4)) | |
| 444 | .frame(width: 8, height: 8) | |
| 445 | Text("\(result.port)") | |
| 446 | .font(.system(.caption, design: .monospaced)) | |
| 447 | .frame(width: 44, alignment: .leading) | |
| 448 | Text(result.service) | |
| 449 | .font(.system(.caption, design: .monospaced)) | |
| 450 | .foregroundStyle(result.open ? .primary : .secondary) | |
| 451 | Spacer() | |
| 452 | if result.open { | |
| 453 | Text("Open") | |
| 454 | .font(.system(.caption2, design: .monospaced)) | |
| 455 | .foregroundStyle(.green) | |
| 456 | } | |
| 457 | } | |
| 458 | } | |
| 459 | } | |
| 460 | .padding(10) | |
| 461 | .background(Color(.systemGray6).opacity(0.5)) | |
| 462 | .cornerRadius(6) | |
| 463 | } | |
| 464 | } | |
| 465 | .padding(.top, 16) | |
| 466 | } | |
| 467 | ||
| 468 | // MARK: - Helpers | |
| 469 | ||
| 470 | private func sectionHeader(_ title: String) -> some View { | |
| 471 | Text(title) | |
| 472 | .font(.system(.headline, design: .default)) | |
| 473 | .foregroundStyle(.white) | |
| 474 | } | |
| 475 | ||
| 476 | private func labelRow(_ label: String, _ value: String) -> some View { | |
| 477 | VStack(alignment: .leading, spacing: 2) { | |
| 478 | Text(label) | |
| 479 | .font(.system(.caption2, design: .monospaced)) | |
| 480 | .foregroundStyle(.secondary) | |
| 481 | Text(value) | |
| 482 | .font(.system(.caption, design: .monospaced)) | |
| 483 | .textSelection(.enabled) | |
| 484 | } | |
| 485 | } | |
| 486 | ||
| 487 | private func recordRows(_ records: [DNSRecord]) -> some View { | |
| 488 | ForEach(records) { record in | |
| 489 | HStack(alignment: .top) { | |
| 490 | Text(record.value) | |
| 491 | .font(.system(.caption, design: .monospaced)) | |
| 492 | .foregroundStyle(.primary) | |
| 493 | .textSelection(.enabled) | |
| 494 | Spacer() | |
| 495 | Text("TTL \(record.ttl)") | |
| 496 | .font(.system(.caption2, design: .monospaced)) | |
| 497 | .foregroundStyle(.secondary) | |
| 498 | } | |
| 499 | } | |
| 500 | } | |
| 501 | ||
| 502 | private func horizontallyScrollableCard<Content: View>( | |
| 503 | spacing: CGFloat = 4, | |
| 504 | @ViewBuilder content: () -> Content | |
| 505 | ) -> some View { | |
| 506 | horizontallyScrollableContent(spacing: spacing) { | |
| 507 | content() | |
| 508 | } | |
| 509 | .padding(10) | |
| 510 | .background(Color(.systemGray6).opacity(0.5)) | |
| 511 | .cornerRadius(6) | |
| 512 | } | |
| 513 | ||
| 514 | private func horizontallyScrollableContent<Content: View>( | |
| 515 | spacing: CGFloat = 4, | |
| 516 | @ViewBuilder content: () -> Content | |
| 517 | ) -> some View { | |
| 518 | ScrollView(.horizontal) { | |
| 519 | VStack(alignment: .leading, spacing: spacing) { | |
| 520 | content() | |
| 521 | } | |
| 522 | .scrollTargetLayout() | |
| 523 | } | |
| 524 | .scrollBounceBehavior(.basedOnSize, axes: .horizontal) | |
| 525 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 526 | } | |
| 527 | ||
| 528 | private func errorLabel(_ message: String) -> some View { | |
| 529 | Label(message, systemImage: "exclamationmark.triangle.fill") | |
| 530 | .font(.system(.caption, design: .monospaced)) | |
| 531 | .foregroundStyle(.red) | |
| 532 | .padding(8) | |
| 533 | } | |
| 534 | ||
| 535 | private func expiryColor(_ days: Int) -> Color { | |
| 536 | if days < 30 { return .red } | |
| 537 | if days < 60 { return .yellow } | |
| 538 | return .green | |
| 539 | } | |
| 540 | 164 | } |
DomainDig/IPGeolocationService.swift +12 −7
| @@ -1,17 +1,22 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | 3 | struct IPGeolocationService { |
| 4 | static func lookup(ip: String) async throws -> IPGeolocation { | |
| 4 | static func lookup(ip: String) async -> ServiceResult<IPGeolocation> { | |
| 5 | 5 | let url = URL(string: "https://ipapi.co/\(ip)/json/")! |
| 6 | 6 | let request = URLRequest(url: url, timeoutInterval: 10) |
| 7 | 7 | |
| 8 | let (data, response) = try await URLSession.shared.data(for: request) | |
| 8 | do { | |
| 9 | let (data, response) = try await URLSession.shared.data(for: request) | |
| 9 | 10 | |
| 10 | guard let httpResponse = response as? HTTPURLResponse, | |
| 11 | httpResponse.statusCode == 200 else { | |
| 12 | throw URLError(.badServerResponse) | |
| 13 | } | |
| 11 | guard let httpResponse = response as? HTTPURLResponse, | |
| 12 | httpResponse.statusCode == 200 else { | |
| 13 | return .error(URLError(.badServerResponse).localizedDescription) | |
| 14 | } | |
| 14 | 15 | |
| 15 | return try JSONDecoder().decode(IPGeolocation.self, from: data) | |
| 16 | let geolocation = try JSONDecoder().decode(IPGeolocation.self, from: data) | |
| 17 | return .success(geolocation) | |
| 18 | } catch { | |
| 19 | return .error(error.localizedDescription) | |
| 20 | } | |
| 16 | 21 | } |
| 17 | 22 | } |
DomainDig/Models.swift +66 −3
| @@ -1,5 +1,11 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | enum ServiceResult<Value> { | |
| 4 | case success(Value) | |
| 5 | case empty(String) | |
| 6 | case error(String) | |
| 7 | } | |
| 8 | ||
| 3 | 9 | // MARK: - DNS Models |
| 4 | 10 | |
| 5 | 11 | enum DNSRecordType: String, CaseIterable, Codable { |
| @@ -13,6 +19,7 @@ enum DNSRecordType: String, CaseIterable, Codable { | ||
| 13 | 19 | case SRV |
| 14 | 20 | case CAA |
| 15 | 21 | case DS |
| 22 | case PTR | |
| 16 | 23 | |
| 17 | 24 | var queryType: Int { |
| 18 | 25 | switch self { |
| @@ -26,6 +33,7 @@ enum DNSRecordType: String, CaseIterable, Codable { | ||
| 26 | 33 | case .SRV: return 33 |
| 27 | 34 | case .CAA: return 257 |
| 28 | 35 | case .DS: return 43 |
| 36 | case .PTR: return 12 | |
| 29 | 37 | } |
| 30 | 38 | } |
| 31 | 39 | |
| @@ -223,18 +231,34 @@ struct RedirectHop: Identifiable, Codable { | ||
| 223 | 231 | |
| 224 | 232 | // MARK: - Port Scan Models |
| 225 | 233 | |
| 234 | enum PortScanKind: String, Codable { | |
| 235 | case standard | |
| 236 | case custom | |
| 237 | } | |
| 238 | ||
| 226 | 239 | struct PortScanResult: Identifiable, Codable { |
| 227 | 240 | var id = UUID() |
| 228 | 241 | let port: UInt16 |
| 229 | 242 | let service: String |
| 230 | 243 | let open: Bool |
| 231 | 244 | var banner: String? |
| 232 | ||
| 233 | nonisolated init(port: UInt16, service: String, open: Bool, banner: String? = nil) { | |
| 245 | let kind: PortScanKind | |
| 246 | let durationMs: Int? | |
| 247 | ||
| 248 | nonisolated init( | |
| 249 | port: UInt16, | |
| 250 | service: String, | |
| 251 | open: Bool, | |
| 252 | banner: String? = nil, | |
| 253 | kind: PortScanKind = .standard, | |
| 254 | durationMs: Int? = nil | |
| 255 | ) { | |
| 234 | 256 | self.port = port |
| 235 | 257 | self.service = service |
| 236 | 258 | self.open = open |
| 237 | 259 | self.banner = banner |
| 260 | self.kind = kind | |
| 261 | self.durationMs = durationMs | |
| 238 | 262 | } |
| 239 | 263 | |
| 240 | 264 | init(from decoder: Decoder) throws { |
| @@ -244,6 +268,8 @@ struct PortScanResult: Identifiable, Codable { | ||
| 244 | 268 | service = try container.decode(String.self, forKey: .service) |
| 245 | 269 | open = try container.decode(Bool.self, forKey: .open) |
| 246 | 270 | banner = try container.decodeIfPresent(String.self, forKey: .banner) |
| 271 | kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard | |
| 272 | durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs) | |
| 247 | 273 | } |
| 248 | 274 | } |
| 249 | 275 | |
| @@ -264,13 +290,28 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 264 | 290 | var redirectChain: [RedirectHop] |
| 265 | 291 | var portScanResults: [PortScanResult] |
| 266 | 292 | var hstsPreloaded: Bool? |
| 293 | var resolverDisplayName: String | |
| 294 | var resolverURLString: String | |
| 295 | var totalLookupDurationMs: Int? | |
| 296 | var sslError: String? | |
| 297 | var httpHeadersError: String? | |
| 298 | var reachabilityError: String? | |
| 299 | var ipGeolocationError: String? | |
| 300 | var emailSecurityError: String? | |
| 301 | var ptrError: String? | |
| 302 | var redirectChainError: String? | |
| 303 | var portScanError: String? | |
| 267 | 304 | |
| 268 | 305 | init(domain: String, timestamp: Date, dnsSections: [DNSSection], |
| 269 | 306 | sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], |
| 270 | 307 | reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, |
| 271 | 308 | emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, |
| 272 | 309 | redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], |
| 273 | hstsPreloaded: Bool? = nil) { | |
| 310 | hstsPreloaded: Bool? = nil, resolverDisplayName: String, resolverURLString: String, | |
| 311 | totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, | |
| 312 | reachabilityError: String? = nil, ipGeolocationError: String? = nil, | |
| 313 | emailSecurityError: String? = nil, ptrError: String? = nil, | |
| 314 | redirectChainError: String? = nil, portScanError: String? = nil) { | |
| 274 | 315 | self.domain = domain |
| 275 | 316 | self.timestamp = timestamp |
| 276 | 317 | self.dnsSections = dnsSections |
| @@ -284,6 +325,17 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 284 | 325 | self.redirectChain = redirectChain |
| 285 | 326 | self.portScanResults = portScanResults |
| 286 | 327 | self.hstsPreloaded = hstsPreloaded |
| 328 | self.resolverDisplayName = resolverDisplayName | |
| 329 | self.resolverURLString = resolverURLString | |
| 330 | self.totalLookupDurationMs = totalLookupDurationMs | |
| 331 | self.sslError = sslError | |
| 332 | self.httpHeadersError = httpHeadersError | |
| 333 | self.reachabilityError = reachabilityError | |
| 334 | self.ipGeolocationError = ipGeolocationError | |
| 335 | self.emailSecurityError = emailSecurityError | |
| 336 | self.ptrError = ptrError | |
| 337 | self.redirectChainError = redirectChainError | |
| 338 | self.portScanError = portScanError | |
| 287 | 339 | } |
| 288 | 340 | |
| 289 | 341 | init(from decoder: Decoder) throws { |
| @@ -302,6 +354,17 @@ struct HistoryEntry: Identifiable, Codable { | ||
| 302 | 354 | redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] |
| 303 | 355 | portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] |
| 304 | 356 | hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) |
| 357 | resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" | |
| 358 | resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString | |
| 359 | totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) | |
| 360 | sslError = try container.decodeIfPresent(String.self, forKey: .sslError) | |
| 361 | httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) | |
| 362 | reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) | |
| 363 | ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) | |
| 364 | emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) | |
| 365 | ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) | |
| 366 | redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) | |
| 367 | portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) | |
| 305 | 368 | } |
| 306 | 369 | } |
| 307 | 370 | |
DomainDig/PortScanService.swift +41 −13
| @@ -20,12 +20,18 @@ struct PortScanService { | ||
| 20 | 20 | PortInfo(port: 8443, service: "HTTPS Alt"), |
| 21 | 21 | ] |
| 22 | 22 | |
| 23 | static func scanAll(domain: String) async -> [PortScanResult] { | |
| 23 | static func scanAll(domain: String) async -> ServiceResult<[PortScanResult]> { | |
| 24 | 24 | await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in |
| 25 | 25 | for info in ports { |
| 26 | 26 | group.addTask { |
| 27 | let open = await probe(domain: domain, port: info.port) | |
| 28 | return PortScanResult(port: info.port, service: info.service, open: open) | |
| 27 | let result = await probe(domain: domain, port: info.port) | |
| 28 | return PortScanResult( | |
| 29 | port: info.port, | |
| 30 | service: info.service, | |
| 31 | open: result.open, | |
| 32 | kind: .standard, | |
| 33 | durationMs: result.durationMs | |
| 34 | ) | |
| 29 | 35 | } |
| 30 | 36 | } |
| 31 | 37 | |
| @@ -35,20 +41,24 @@ struct PortScanService { | ||
| 35 | 41 | } |
| 36 | 42 | |
| 37 | 43 | // Sort by port number |
| 38 | return results.sorted { $0.port < $1.port } | |
| 44 | let sorted = results.sorted { $0.port < $1.port } | |
| 45 | return sorted.isEmpty ? [] : sorted | |
| 39 | 46 | } |
| 47 | .pipe { $0.isEmpty ? .empty("No port scan results") : .success($0) } | |
| 40 | 48 | } |
| 41 | 49 | |
| 42 | static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> [PortScanResult] { | |
| 50 | static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> ServiceResult<[PortScanResult]> { | |
| 43 | 51 | await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in |
| 44 | 52 | for port in ports { |
| 45 | 53 | let service = self.ports.first(where: { $0.port == port })?.service ?? "Custom" |
| 46 | 54 | group.addTask { |
| 47 | let open = await probe(domain: domain, port: port, timeout: timeout) | |
| 55 | let result = await probe(domain: domain, port: port, timeout: timeout) | |
| 48 | 56 | return PortScanResult( |
| 49 | 57 | port: port, |
| 50 | 58 | service: service, |
| 51 | open: open | |
| 59 | open: result.open, | |
| 60 | kind: .custom, | |
| 61 | durationMs: result.durationMs | |
| 52 | 62 | ) |
| 53 | 63 | } |
| 54 | 64 | } |
| @@ -58,8 +68,10 @@ struct PortScanService { | ||
| 58 | 68 | results.append(result) |
| 59 | 69 | } |
| 60 | 70 | |
| 61 | return results.sorted { $0.port < $1.port } | |
| 71 | let sorted = results.sorted { $0.port < $1.port } | |
| 72 | return sorted.isEmpty ? [] : sorted | |
| 62 | 73 | } |
| 74 | .pipe { $0.isEmpty ? .empty("No custom port scan results") : .success($0) } | |
| 63 | 75 | } |
| 64 | 76 | |
| 65 | 77 | static func grabBanner(host: String, port: UInt16, timeout: TimeInterval = 3.0) async -> String? { |
| @@ -111,11 +123,11 @@ struct PortScanService { | ||
| 111 | 123 | } |
| 112 | 124 | } |
| 113 | 125 | |
| 114 | private static func probe(domain: String, port: UInt16) async -> Bool { | |
| 126 | private static func probe(domain: String, port: UInt16) async -> PortProbeResult { | |
| 115 | 127 | await probe(domain: domain, port: port, timeout: 5) |
| 116 | 128 | } |
| 117 | 129 | |
| 118 | private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> Bool { | |
| 130 | private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult { | |
| 119 | 131 | await withCheckedContinuation { continuation in |
| 120 | 132 | let host = NWEndpoint.Host(domain) |
| 121 | 133 | let nwPort = NWEndpoint.Port(rawValue: port)! |
| @@ -143,13 +155,19 @@ struct PortScanService { | ||
| 143 | 155 | } |
| 144 | 156 | } |
| 145 | 157 | |
| 158 | private struct PortProbeResult: Sendable { | |
| 159 | let open: Bool | |
| 160 | let durationMs: Int? | |
| 161 | } | |
| 162 | ||
| 146 | 163 | private final class ProbeContext: @unchecked Sendable { |
| 147 | 164 | private let connection: NWConnection |
| 148 | private let continuation: CheckedContinuation<Bool, Never> | |
| 165 | private let continuation: CheckedContinuation<PortProbeResult, Never> | |
| 166 | private let start = CFAbsoluteTimeGetCurrent() | |
| 149 | 167 | private let lock = NSLock() |
| 150 | 168 | private nonisolated(unsafe) var resumed = false |
| 151 | 169 | |
| 152 | init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) { | |
| 170 | init(connection: NWConnection, continuation: CheckedContinuation<PortProbeResult, Never>) { | |
| 153 | 171 | self.connection = connection |
| 154 | 172 | self.continuation = continuation |
| 155 | 173 | } |
| @@ -164,7 +182,17 @@ private final class ProbeContext: @unchecked Sendable { | ||
| 164 | 182 | lock.unlock() |
| 165 | 183 | |
| 166 | 184 | connection.cancel() |
| 167 | continuation.resume(returning: open) | |
| 185 | let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000) | |
| 186 | continuation.resume(returning: PortProbeResult( | |
| 187 | open: open, | |
| 188 | durationMs: elapsedMs >= 0 ? elapsedMs : nil | |
| 189 | )) | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 193 | private extension Array { | |
| 194 | func pipe<T>(_ transform: (Self) -> T) -> T { | |
| 195 | transform(self) | |
| 168 | 196 | } |
| 169 | 197 | } |
| 170 | 198 | |
DomainDig/ReachabilityService.swift +3 −2
| @@ -29,10 +29,11 @@ struct ReachabilityService { | ||
| 29 | 29 | } |
| 30 | 30 | } |
| 31 | 31 | |
| 32 | static func checkAll(domain: String) async -> [PortReachability] { | |
| 32 | static func checkAll(domain: String) async -> ServiceResult<[PortReachability]> { | |
| 33 | 33 | async let port443 = check(domain: domain, port: 443) |
| 34 | 34 | async let port80 = check(domain: domain, port: 80) |
| 35 | return await [port443, port80] | |
| 35 | let results = await [port443, port80] | |
| 36 | return results.isEmpty ? .empty("No reachability results") : .success(results) | |
| 36 | 37 | } |
| 37 | 38 | } |
| 38 | 39 | |
DomainDig/RedirectChainService.swift +9 −4
| @@ -1,12 +1,17 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | 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 | |
| 4 | static func trace(domain: String) async -> ServiceResult<[RedirectHop]> { | |
| 6 | 5 | do { |
| 7 | return try await followChain(startingURL: URL(string: "https://\(domain)")!) | |
| 6 | let hops = try await followChain(startingURL: URL(string: "https://\(domain)")!) | |
| 7 | return hops.isEmpty ? .empty("No redirect data available") : .success(hops) | |
| 8 | 8 | } catch { |
| 9 | return try await followChain(startingURL: URL(string: "http://\(domain)")!) | |
| 9 | do { | |
| 10 | let hops = try await followChain(startingURL: URL(string: "http://\(domain)")!) | |
| 11 | return hops.isEmpty ? .empty("No redirect data available") : .success(hops) | |
| 12 | } catch { | |
| 13 | return .error(error.localizedDescription) | |
| 14 | } | |
| 10 | 15 | } |
| 11 | 16 | } |
| 12 | 17 | |
DomainDig/ReverseDNSService.swift +14 −35
| @@ -1,48 +1,27 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | 3 | struct ReverseDNSService { |
| 4 | /// Look up the PTR record for an IPv4 address via Cloudflare DoH. | |
| 5 | static func lookup(ip: String) async -> String? { | |
| 4 | static func lookup(ip: String, resolverURLString: String) async -> ServiceResult<String> { | |
| 6 | 5 | let octets = ip.split(separator: ".") |
| 7 | guard octets.count == 4 else { return nil } | |
| 6 | guard octets.count == 4 else { | |
| 7 | return .error("Invalid IPv4 address") | |
| 8 | } | |
| 8 | 9 | |
| 9 | 10 | let reversed = octets.reversed().joined(separator: ".") |
| 10 | 11 | let ptrDomain = "\(reversed).in-addr.arpa" |
| 11 | 12 | |
| 12 | // PTR record type = 12 | |
| 13 | 13 | do { |
| 14 | let records = try await lookupPTR(domain: ptrDomain) | |
| 15 | return records.first | |
| 14 | let records = try await DNSLookupService.lookup( | |
| 15 | domain: ptrDomain, | |
| 16 | recordType: .PTR, | |
| 17 | resolverURLString: resolverURLString | |
| 18 | ) | |
| 19 | if let record = records.first?.value { | |
| 20 | return .success(record) | |
| 21 | } | |
| 22 | return .empty("No PTR record found") | |
| 16 | 23 | } catch { |
| 17 | return nil | |
| 24 | return .error(error.localizedDescription) | |
| 18 | 25 | } |
| 19 | 26 | } |
| 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 | 27 | } |
DomainDig/SSLCheckService.swift +10 −7
| @@ -3,7 +3,7 @@ import Security | ||
| 3 | 3 | |
| 4 | 4 | struct SSLCheckService { |
| 5 | 5 | |
| 6 | static func check(domain: String) async throws -> SSLCertificateInfo { | |
| 6 | static func check(domain: String) async -> ServiceResult<SSLCertificateInfo> { | |
| 7 | 7 | let delegate = SSLSessionDelegate() |
| 8 | 8 | let session = URLSession( |
| 9 | 9 | configuration: .ephemeral, |
| @@ -15,14 +15,17 @@ struct SSLCheckService { | ||
| 15 | 15 | let url = URL(string: "https://\(domain)")! |
| 16 | 16 | let request = URLRequest(url: url, timeoutInterval: 10) |
| 17 | 17 | |
| 18 | // We only need to establish the connection to grab the cert | |
| 19 | _ = try await session.data(for: request) | |
| 18 | do { | |
| 19 | _ = try await session.data(for: request) | |
| 20 | 20 | |
| 21 | guard let trust = delegate.serverTrust else { | |
| 22 | throw SSLError.noCertificate | |
| 23 | } | |
| 21 | guard let trust = delegate.serverTrust else { | |
| 22 | return .empty(SSLError.noCertificate.localizedDescription) | |
| 23 | } | |
| 24 | 24 | |
| 25 | return try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata) | |
| 25 | return .success(try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata)) | |
| 26 | } catch { | |
| 27 | return .error(error.localizedDescription) | |
| 28 | } | |
| 26 | 29 | } |
| 27 | 30 | |
| 28 | 31 | static func checkHSTSPreload(domain: String) async -> Bool? { |