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