From ee2520f2c0c2d806fd284c3d6b8a81a3822f5270 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Mon, 20 Apr 2026 15:28:12 -0500 Subject: feat(v1.8): improve result structure, history, and consistency - normalize sections and add summary card - fix resolver usage in reverse DNS - include custom port scans in history/export - standardize error handling - decompose ContentView into smaller views --- DomainDig/ContentView.swift | 1184 ++++++++++++---------------- DomainDig/DNSLookupService.swift | 23 +- DomainDig/DomainViewModel.swift | 1441 +++++++++++++++++++++++----------- DomainDig/EmailSecurityService.swift | 7 +- DomainDig/HTTPHeadersService.swift | 61 +- DomainDig/HistoryView.swift | 562 +++---------- DomainDig/IPGeolocationService.swift | 19 +- DomainDig/Models.swift | 69 +- DomainDig/PortScanService.swift | 54 +- DomainDig/ReachabilityService.swift | 5 +- DomainDig/RedirectChainService.swift | 13 +- DomainDig/ReverseDNSService.swift | 49 +- DomainDig/SSLCheckService.swift | 17 +- 13 files changed, 1789 insertions(+), 1715 deletions(-) diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift index d9c1299..68f3a6f 100644 --- a/DomainDig/ContentView.swift +++ b/DomainDig/ContentView.swift @@ -1,5 +1,5 @@ -import SwiftUI import MapKit +import SwiftUI struct ContentView: View { @State private var viewModel = DomainViewModel() @@ -14,14 +14,59 @@ struct ContentView: View { inputSection if viewModel.hasRun { actionButtons - reachabilitySection - redirectChainSection - dnsResultsSection - emailSecuritySection - sslResultsSection - httpHeadersSection - ipGeolocationSection - portScanSection + SummaryView(fields: viewModel.summaryFields) + .padding(.top, 8) + DomainSectionView(rows: viewModel.domainRows) + .padding(.top, 16) + DNSSectionView( + dnssecLabel: viewModel.dnssecLabel, + sections: viewModel.dnsRows, + ptrMessage: viewModel.ptrMessage, + loading: viewModel.dnsLoading || viewModel.ptrLoading, + sectionError: viewModel.dnsError + ) + .padding(.top, 16) + WebSectionView( + certificateRows: viewModel.webCertificateRows, + sslInfo: viewModel.sslInfo, + sslLoading: viewModel.sslLoading || viewModel.hstsLoading, + sslError: viewModel.sslError, + responseRows: viewModel.webResponseRows, + headers: viewModel.httpHeaders, + headersLoading: viewModel.httpHeadersLoading, + headersError: viewModel.httpHeadersError, + redirects: viewModel.redirectRows, + redirectLoading: viewModel.redirectChainLoading, + redirectError: viewModel.redirectChainError, + finalURL: viewModel.currentSnapshot.redirectChain.last?.url + ) + .padding(.top, 16) + EmailSectionView( + rows: viewModel.emailRows, + loading: viewModel.emailSecurityLoading, + error: viewModel.emailSecurityError + ) + .padding(.top, 16) + NetworkSectionView( + reachabilityRows: viewModel.reachabilityRows, + reachabilityLoading: viewModel.reachabilityLoading, + reachabilityError: viewModel.reachabilityError, + locationRows: viewModel.locationRows, + geolocation: viewModel.ipGeolocation, + geolocationLoading: viewModel.ipGeolocationLoading, + geolocationError: viewModel.ipGeolocationError, + standardPortRows: viewModel.standardPortRows, + customPortRows: viewModel.customPortRows, + portScanLoading: viewModel.portScanLoading, + portScanError: viewModel.portScanError, + customPortScanLoading: viewModel.customPortScanLoading, + customPortScanError: viewModel.customPortScanError, + isCloudflareProxied: viewModel.isCloudflareProxied, + customPortsExpanded: $customPortsExpanded, + customPortInput: $customPortInput, + onScanCustomPorts: runCustomPortScan + ) + .padding(.top, 16) } else if !viewModel.recentSearches.isEmpty { recentSearchesSection } @@ -55,8 +100,6 @@ struct ContentView: View { Image(systemName: "clock.arrow.trianglehead.counterclockwise.rotate.90") .foregroundStyle(.secondary) } - } - ToolbarItem(placement: .topBarTrailing) { NavigationLink { SettingsView() } label: { @@ -71,8 +114,6 @@ struct ContentView: View { } } - // MARK: - Input - private var inputSection: some View { VStack(spacing: 12) { TextField("e.g. cleberg.net", text: $viewModel.domain) @@ -101,8 +142,6 @@ struct ContentView: View { .padding(.vertical, 16) } - // MARK: - Action Buttons (Share + Bookmark) - private var actionButtons: some View { HStack { Spacer() @@ -123,11 +162,8 @@ struct ContentView: View { } } } - .padding(.top, 8) } - // MARK: - Recent Searches - private var recentSearchesSection: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -162,778 +198,605 @@ struct ContentView: View { .padding(.top, 8) } - // MARK: - Reachability - - private var reachabilitySection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader("Reachability") - - if viewModel.reachabilityLoading { - ProgressView("Checking ports…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.reachabilityError { - errorLabel(error) - } else { - VStack(alignment: .leading, spacing: 4) { - ForEach(viewModel.reachabilityResults) { result in - HStack(spacing: 8) { - Circle() - .fill(result.reachable ? Color.green : Color.red) - .frame(width: 8, height: 8) - Text("Port \(result.port)") - .font(.system(.caption, design: .monospaced)) - if result.reachable, let ms = result.latencyMs { - Text("\(ms)ms") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } else if !result.reachable { - Text("—") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - Spacer() - Text(result.reachable ? "Reachable" : "Unreachable") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(result.reachable ? .green : .red) - } - } - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } + private func runCustomPortScan() { + let ports = parsedCustomPorts(from: customPortInput) + Task { + await viewModel.runCustomPortScan(ports: ports) } - .padding(.top, 8) } - // MARK: - Redirect Chain - - private var redirectChainSection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader("Redirect Chain") - - if viewModel.redirectChainLoading { - ProgressView("Tracing redirects…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.redirectChainError { - errorLabel(error) - } else if viewModel.redirectChain.isEmpty { - Text("No redirect data") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(8) - } else if viewModel.redirectChain.count == 1, - let only = viewModel.redirectChain.first, - only.isFinal, !(300...399).contains(only.statusCode) { - Text("No redirects — direct connection") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } else { - horizontallyScrollableCard { - ForEach(viewModel.redirectChain) { hop in - HStack(alignment: .top, spacing: 6) { - Text("\(hop.stepNumber)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 16, alignment: .trailing) - Text("\(hop.statusCode)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.cyan) - .frame(width: 30, alignment: .leading) - Text(hop.url) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - if hop.isFinal { - Text("(final)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - .padding(.top, 16) - } - - // MARK: - DNS Results + private func parsedCustomPorts(from input: String) -> [UInt16] { + let parts = input.split(separator: ",", omittingEmptySubsequences: true) + var seen = Set() + var ports: [UInt16] = [] - private var dnsResultsSection: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 8) { - sectionHeader("DNS Records") - Spacer() - if let dnssecSigned = dnssecStatus { - Text(dnssecSigned ? "DNSSEC ✓" : "DNSSEC ✗") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(dnssecSigned ? .green : .red) - .padding(.top, 1) - } + for part in parts { + let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) + guard let value = UInt16(trimmed), seen.insert(value).inserted else { + continue } - - if viewModel.dnsLoading { - ProgressView("Querying DNS…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.dnsError { - errorLabel(error) - } else { - ForEach(viewModel.dnsSections) { section in - dnsRecordSection(section) - if section.recordType == .A { - ptrRow - } - } + ports.append(value) + if ports.count == 20 { + break } } - .padding(.top, 16) - } - private var ptrRow: some View { - horizontallyScrollableCard { - Text("PTR (Reverse DNS)") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) - - if viewModel.ptrLoading { - ProgressView() - .frame(maxWidth: .infinity, alignment: .center) - .padding(4) - } else if let ptr = viewModel.ptrRecord { - Text(ptr) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } else { - Text("No PTR record found") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - } + return ports } - private func dnsRecordSection(_ section: DNSSection) -> some View { - horizontallyScrollableCard { - Text(section.recordType.rawValue) - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) - - if let error = section.error { - errorLabel(error) - } else if section.records.isEmpty { - Text("No records found") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } else { - dnsRecordRows(section.records) - } - - if !section.wildcardRecords.isEmpty { - Text("*.\(viewModel.searchedDomain)") - .font(.system(.caption, design: .monospaced)) - .fontWeight(.medium) - .foregroundStyle(.cyan.opacity(0.7)) - .padding(.top, 4) + private func shareResults() { + let text = viewModel.exportText() + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyyMMdd_HHmmss" + let timestamp = dateFmt.string(from: Date()) + let filename = "\(timestamp)_domaindigresults.txt" + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - dnsRecordRows(section.wildcardRecords) - } + do { + try text.write(to: tempURL, atomically: true, encoding: .utf8) + } catch { + return } - } - private func dnsRecordRows(_ records: [DNSRecord]) -> some View { - ForEach(records) { record in - HStack(alignment: .top) { - Text(record.value) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - Spacer() - Text("TTL \(record.ttl)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } + let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let rootVC = windowScene.keyWindow?.rootViewController else { return } + var presenter = rootVC + while let presented = presenter.presentedViewController { + presenter = presented } + activityVC.popoverPresentationController?.sourceView = presenter.view + presenter.present(activityVC, animated: true) } +} - // MARK: - Email Security - - @State private var expandedEmailField: String? +struct SummaryView: View { + let fields: [SummaryFieldViewData] - private var emailSecuritySection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("Email Security") - - if viewModel.emailSecurityLoading { - ProgressView("Checking email records…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.emailSecurityError { - errorLabel(error) - } else if let email = viewModel.emailSecurity { - horizontallyScrollableCard(spacing: 6) { - emailSecurityRow("SPF", record: email.spf) - emailSecurityRow("DMARC", record: email.dmarc) - emailSecurityRow("DKIM", record: email.dkim) - emailSecurityRow("MTA-STS", mtaSts: email.mtaSts) - emailSecurityRow("BIMI", record: email.bimi) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - } - - private func emailSecurityRow(_ label: String, record: EmailSecurityRecord) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text(label) - .font(.system(.caption, design: .monospaced)) - .fontWeight(.semibold) - .frame(width: 72, alignment: .leading) - Text(record.found ? "✓" : "✗") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(record.found ? .green : .red) - if let value = record.value { - let isExpanded = expandedEmailField == label - let displayValue = isExpanded ? value : String(value.prefix(80)) - Text(displayValue) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - .lineLimit(isExpanded ? nil : 1) - .onTapGesture { - withAnimation { - expandedEmailField = isExpanded ? nil : label - } - } - if let selector = record.matchedSelector { - Text("(selector: \(selector))") + SectionTitleView(title: "Summary") + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) { + ForEach(fields) { field in + VStack(alignment: .leading, spacing: 4) { + Text(field.label) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) + Text(field.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: field.tone)) + .lineLimit(2) + .textSelection(.enabled) } - } else { - Text("No record found") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - - private func emailSecurityRow(_ label: String, mtaSts: MTASTSResult?) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text(label) - .font(.system(.caption, design: .monospaced)) - .fontWeight(.semibold) - .frame(width: 72, alignment: .leading) - Text(mtaSts?.txtFound == true ? "✓" : "✗") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) - if let policyMode = mtaSts?.policyMode { - Text(policyMode) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } else { - Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) } } } } +} - // MARK: - SSL Results +struct DomainSectionView: View { + let rows: [InfoRowViewData] - private var sslResultsSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("SSL / TLS Certificate") - - if viewModel.sslLoading { - ProgressView("Checking certificate…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.sslError { - errorLabel(error) - } else if let info = viewModel.sslInfo { - sslDetail(info, domain: viewModel.searchedDomain) + SectionTitleView(title: "Domain") + CardView { + ForEach(rows) { row in + LabeledValueRow(row: row) + } } } - .padding(.top, 16) } +} - private func sslDetail(_ info: SSLCertificateInfo, domain: String) -> some View { - horizontallyScrollableCard(spacing: 8) { - certRow("Common Name", info.commonName) - certRow("Issuer", info.issuer) +struct DNSSectionView: View { + let dnssecLabel: String? + let sections: [DNSRecordSectionViewData] + let ptrMessage: SectionMessageViewData? + let loading: Bool + let sectionError: String? - VStack(alignment: .leading, spacing: 2) { - Text("SANs") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - ForEach(info.subjectAltNames, id: \.self) { san in - Text(san) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) + var body: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 8) { + SectionTitleView(title: "DNS") + Spacer() + if let dnssecLabel { + Text(dnssecLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.trailing) } } - let formatter = DateFormatter.certDate - certRow("Valid From", formatter.string(from: info.validFrom)) - certRow("Valid Until", formatter.string(from: info.validUntil)) + if loading { + LoadingCardView(text: "Querying DNS…") + } else if let sectionError, sections.isEmpty { + MessageCardView(text: sectionError, isError: true) + } else { + ForEach(sections) { section in + CardView { + Text(section.title) + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + + if let message = section.message { + MessageRowView(text: message.text, isError: message.isError) + } - VStack(alignment: .leading, spacing: 2) { - Text("Days Until Expiry") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text("\(info.daysUntilExpiry)") - .font(.system(.caption, design: .monospaced)) - .fontWeight(.bold) - .foregroundStyle(expiryColor(info.daysUntilExpiry)) - } + ForEach(section.rows) { row in + LabeledValueRow(row: row) + } - certRow("Chain Depth", "\(info.chainDepth)") - if viewModel.hstsLoading { - hstsLoadingRow - } else if let hstsPreloaded = viewModel.hstsPreloaded { - hstsStatusRow(hstsPreloaded) - } - if let tlsVersion = info.tlsVersion { - certRow("TLS Version", tlsVersion) - } - if let cipherSuite = info.cipherSuite { - certRow("Cipher Suite", cipherSuite) - } - if !info.chain.isEmpty { - VStack(alignment: .leading, spacing: 6) { - Text("Certificate Chain") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - ForEach(Array(info.chain.enumerated()), id: \.offset) { index, certificate in - DisclosureGroup { - Text(certificate.issuer) + if let wildcardTitle = section.wildcardTitle { + Text(wildcardTitle) .font(.system(.caption, design: .monospaced)) .foregroundStyle(.secondary) - .textSelection(.enabled) - } label: { - Text(certificate.subject) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) + .padding(.top, 4) + ForEach(section.wildcardRows) { row in + LabeledValueRow(row: row) + } } - .tint(index == 0 ? .cyan : .secondary) + } + } + + if let ptrMessage { + CardView { + Text("PTR") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + MessageRowView(text: ptrMessage.text, isError: ptrMessage.isError) } } } - Link("View on crt.sh →", destination: URL(string: "https://crt.sh/?q=\(domain)")!) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.cyan) } } +} - // MARK: - HTTP Headers +struct WebSectionView: View { + let certificateRows: [InfoRowViewData] + let sslInfo: SSLCertificateInfo? + let sslLoading: Bool + let sslError: String? + let responseRows: [InfoRowViewData] + let headers: [HTTPHeader] + let headersLoading: Bool + let headersError: String? + let redirects: [RedirectHopViewData] + let redirectLoading: Bool + let redirectError: String? + let finalURL: String? - private var httpHeadersSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - sectionHeader("HTTP Headers") - if let grade = viewModel.httpSecurityGrade { - Text(grade) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(httpSecurityGradeColor(for: grade)) - .padding(.horizontal, 8) - .padding(.vertical, 2) - .background(httpSecurityGradeColor(for: grade).opacity(0.18)) - .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + SectionTitleView(title: "Web") + + CardView { + Text("TLS") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if sslLoading { + ProgressView("Checking certificate…") + } else if let sslError { + MessageRowView(text: sslError, isError: true) + } else { + ForEach(certificateRows) { row in + LabeledValueRow(row: row) + } + if let sslInfo, !sslInfo.subjectAltNames.isEmpty { + Text("SANs") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + ForEach(sslInfo.subjectAltNames, id: \.self) { san in + Text(san) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + } + } } - Spacer() } - if viewModel.httpHeadersLoading { - ProgressView("Fetching headers…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.httpHeadersError { - errorLabel(error) - } else { - horizontallyScrollableCard { - if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil { - HStack(alignment: .top, spacing: 0) { - ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in - if index > 0 { - Text(" ") - .font(.system(.caption, design: .monospaced)) - } - Text(part.text) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(part.color) - } - if let http3AvailabilityNote { - Text(" ") + CardView { + Text("Headers") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if headersLoading { + ProgressView("Fetching headers…") + } else if let headersError { + MessageRowView(text: headersError, isError: true) + } else { + ForEach(responseRows) { row in + LabeledValueRow(row: row) + } + if headers.isEmpty { + MessageRowView(text: "No HTTP headers returned", isError: false) + } else { + ForEach(headers) { header in + HStack(alignment: .top, spacing: 4) { + Text(header.name + ":") .font(.system(.caption, design: .monospaced)) - Text(http3AvailabilityNote) + .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) + Text(header.value) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) + .foregroundStyle(.primary) + .textSelection(.enabled) } } } - ForEach(viewModel.httpHeaders) { header in - HStack(alignment: .top, spacing: 4) { - Text(header.name + ":") + } + } + + CardView { + Text("Redirects") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if redirectLoading { + ProgressView("Tracing redirects…") + } else if let redirectError { + MessageRowView(text: redirectError, isError: true) + } else if redirects.isEmpty { + MessageRowView(text: "No redirect data available", isError: false) + } else { + if let finalURL { + LabeledValueRow(row: InfoRowViewData(label: "Final URL", value: finalURL, tone: .secondary)) + } + ForEach(redirects) { redirect in + HStack(alignment: .top, spacing: 6) { + Text(redirect.stepLabel) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) - Text(header.value) + .foregroundStyle(.secondary) + .frame(width: 16, alignment: .trailing) + Text(redirect.statusCode) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(width: 36, alignment: .leading) + Text(redirect.url) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) .textSelection(.enabled) + if redirect.isFinal { + Text("(final)") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } } } } } } - .padding(.top, 16) } +} - // MARK: - IP Geolocation +struct EmailSectionView: View { + let rows: [EmailRowViewData] + let loading: Bool + let error: String? - private var ipGeolocationSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("IP Location") - - if viewModel.ipGeolocationLoading { - ProgressView("Looking up location…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let geo = viewModel.ipGeolocation { - ipGeolocationDetail(geo) - } else if let error = viewModel.ipGeolocationError { - if error == "No A record available" { - Text("No location data available") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(8) + SectionTitleView(title: "Email") + CardView { + if loading { + ProgressView("Checking email records…") + } else if let error { + MessageRowView(text: error, isError: true) + } else if rows.isEmpty { + MessageRowView(text: "No email security records found", isError: false) } else { - errorLabel(error) - } - } - } - .padding(.top, 16) - } - - private func ipGeolocationDetail(_ geo: IPGeolocation) -> some View { - VStack(alignment: .leading, spacing: 6) { - horizontallyScrollableContent(spacing: 6) { - certRow("IP", geo.ip) - if let org = geo.org { - certRow("Org / ISP", org) - } - let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") - if !location.isEmpty { - certRow("Location", location) - } - } - - if let lat = geo.latitude, let lon = geo.longitude { - let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) - Map(initialPosition: .region(MKCoordinateRegion( - center: coordinate, - span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) - ))) { - Marker(geo.ip, coordinate: coordinate) + ForEach(rows) { row in + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Text(row.label) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.cyan) + .frame(width: 76, alignment: .leading) + Text(row.status) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.statusTone)) + } + Text(row.detail) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.primary) + .textSelection(.enabled) + if let auxiliaryDetail = row.auxiliaryDetail { + Text(auxiliaryDetail) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + } + } + } } - .mapStyle(.standard) - .frame(maxWidth: .infinity) - .frame(height: 180) - .cornerRadius(8) } } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) } +} - // MARK: - Port Scan +struct NetworkSectionView: View { + let reachabilityRows: [ReachabilityRowViewData] + let reachabilityLoading: Bool + let reachabilityError: String? + let locationRows: [InfoRowViewData] + let geolocation: IPGeolocation? + let geolocationLoading: Bool + let geolocationError: String? + let standardPortRows: [PortScanRowViewData] + let customPortRows: [PortScanRowViewData] + let portScanLoading: Bool + let portScanError: String? + let customPortScanLoading: Bool + let customPortScanError: String? + let isCloudflareProxied: Bool + @Binding var customPortsExpanded: Bool + @Binding var customPortInput: String + let onScanCustomPorts: () -> Void - private var portScanSection: some View { + var body: some View { VStack(alignment: .leading, spacing: 12) { - sectionHeader("Open Ports") - - if viewModel.portScanLoading { - ProgressView("Scanning ports…") - .frame(maxWidth: .infinity, alignment: .center) - .padding() - } else if let error = viewModel.portScanError { - errorLabel(error) - } else { - VStack(alignment: .leading, spacing: 12) { - if viewModel.isCloudflareProxied { - 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.") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.orange) - .padding(8) - .background(Color.orange.opacity(0.1)) - .cornerRadius(6) - } - portScanResultsCard(viewModel.portScanResults) + SectionTitleView(title: "Network") - DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { - VStack(alignment: .leading, spacing: 10) { - TextField("8888, 9000, 27017", text: $customPortInput) + CardView { + Text("Reachability") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if reachabilityLoading { + ProgressView("Checking ports…") + } else if let reachabilityError { + MessageRowView(text: reachabilityError, isError: true) + } else { + ForEach(reachabilityRows) { row in + HStack { + Text(row.portLabel) .font(.system(.caption, design: .monospaced)) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.numberPad) - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - - Button("Scan") { - let ports = parsedCustomPorts(from: customPortInput) - Task { - await viewModel.runCustomPortScan(ports: ports) - } - } - .buttonStyle(.borderedProminent) - .tint(.blue) - .disabled(viewModel.customPortScanLoading) - - if viewModel.customPortScanLoading { - ProgressView("Scanning custom ports…") - .font(.system(.caption, design: .monospaced)) - } else if let error = viewModel.customPortScanError { - errorLabel(error) - } else if !viewModel.customPortResults.isEmpty { - portScanResultsCard(viewModel.customPortResults) - } + Spacer() + Text(row.latencyLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(row.statusLabel) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.statusTone)) } - .padding(.top, 8) } - .font(.system(.caption, design: .monospaced)) - .tint(.secondary) } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) } - } - .padding(.top, 16) - } - // MARK: - Helpers + CardView { + Text("Location") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) + if geolocationLoading { + ProgressView("Looking up location…") + } else if let geolocationError, geolocation == nil { + MessageRowView(text: geolocationError, isError: geolocationError != "No A record available") + } else if let geolocation { + ForEach(locationRows) { row in + LabeledValueRow(row: row) + } + if let latitude = geolocation.latitude, let longitude = geolocation.longitude { + let coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + Map(initialPosition: .region(MKCoordinateRegion( + center: coordinate, + span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) + ))) { + Marker(geolocation.ip, coordinate: coordinate) + } + .mapStyle(.standard) + .frame(height: 180) + .cornerRadius(8) + } + } else { + MessageRowView(text: "No location data available", isError: false) + } + } - private func sectionHeader(_ title: String) -> some View { - Text(title) - .font(.system(.headline, design: .default)) - .foregroundStyle(.white) - } + CardView { + Text("Port Scan") + .font(.system(.subheadline, design: .monospaced)) + .fontWeight(.semibold) + .foregroundStyle(.cyan) - private var dnssecStatus: Bool? { - viewModel.dnsSections.compactMap(\.dnssecSigned).first - } + if isCloudflareProxied { + Text("Domain is behind Cloudflare's proxy. Results reflect the edge, not the origin.") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.orange) + } - private func certRow(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } + if portScanLoading { + ProgressView("Scanning ports…") + } else if let portScanError, standardPortRows.isEmpty { + MessageRowView(text: portScanError, isError: true) + } else { + Text("Standard Ports") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(.secondary) + PortRowsView(rows: standardPortRows) + } - private var hstsLoadingRow: some View { - VStack(alignment: .leading, spacing: 2) { - Text("HSTS Preload") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - ProgressView() - .controlSize(.small) - } - } + DisclosureGroup("Custom Ports", isExpanded: $customPortsExpanded) { + VStack(alignment: .leading, spacing: 10) { + TextField("8888, 9000, 27017", text: $customPortInput) + .font(.system(.caption, design: .monospaced)) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.numberPad) + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) - private func hstsStatusRow(_ isPreloaded: Bool) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text("HSTS Preload") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text(isPreloaded ? "Preloaded" : "Not preloaded") + Button("Scan") { + onScanCustomPorts() + } + .buttonStyle(.borderedProminent) + .disabled(customPortScanLoading) + + if customPortScanLoading { + ProgressView("Scanning custom ports…") + } else if let customPortScanError { + MessageRowView(text: customPortScanError, isError: true) + } else { + PortRowsView(rows: customPortRows) + } + } + .padding(.top, 8) + } .font(.system(.caption, design: .monospaced)) - .foregroundStyle(isPreloaded ? .green : .secondary) - } - } - - private func horizontallyScrollableCard( - spacing: CGFloat = 4, - @ViewBuilder content: () -> Content - ) -> some View { - horizontallyScrollableContent(spacing: spacing) { - content() - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } - - private func horizontallyScrollableContent( - spacing: CGFloat = 4, - @ViewBuilder content: () -> Content - ) -> some View { - ScrollView(.horizontal) { - VStack(alignment: .leading, spacing: spacing) { - content() + .tint(.secondary) } - .scrollTargetLayout() } - .scrollBounceBehavior(.basedOnSize, axes: .horizontal) - .frame(maxWidth: .infinity, alignment: .leading) } +} - private func errorLabel(_ message: String) -> some View { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.red) - .padding(8) - } +struct PortRowsView: View { + let rows: [PortScanRowViewData] - private func portScanResultsCard(_ results: [PortScanResult]) -> some View { - VStack(alignment: .leading, spacing: 4) { - ForEach(results) { result in + var body: some View { + if rows.isEmpty { + MessageRowView(text: "No results", isError: false) + } else { + ForEach(rows) { row in VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Circle() - .fill(result.open ? Color.green : Color(.systemGray4)) - .frame(width: 8, height: 8) - Text("\(result.port)") + HStack { + Text(row.portLabel) .font(.system(.caption, design: .monospaced)) - .lineLimit(1) .frame(width: 52, alignment: .leading) - Text(result.service) + Text(row.service) .font(.system(.caption, design: .monospaced)) - .foregroundStyle(result.open ? .primary : .secondary) + .foregroundStyle(.primary) Spacer() - if result.open { - Text("Open") + if let durationLabel = row.durationLabel { + Text(durationLabel) .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.green) + .foregroundStyle(.secondary) } + Text(row.statusLabel) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.statusTone)) } - - if let banner = result.banner { + if let banner = row.banner { Text(banner) .font(.system(.caption2, design: .monospaced)) .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.leading, 16) + .padding(.leading, 8) } } } } } +} - private func parsedCustomPorts(from input: String) -> [UInt16] { - let parts = input.split(separator: ",", omittingEmptySubsequences: true) - var seen = Set() - var ports: [UInt16] = [] - - for part in parts { - let trimmed = part.trimmingCharacters(in: .whitespacesAndNewlines) - guard let value = UInt16(trimmed), seen.insert(value).inserted else { - continue - } - ports.append(value) - if ports.count == 20 { - break - } - } +struct SectionTitleView: View { + let title: String - return ports + var body: some View { + Text(title) + .font(.system(.headline)) + .foregroundStyle(.white) } +} - private var httpStatusSummaryParts: [(text: String, color: Color)] { - var parts: [(text: String, color: Color)] = [] +struct CardView: View { + let content: Content - if let statusCode = viewModel.httpStatusCode { - parts.append(("HTTP \(statusCode)", .cyan)) - } - if let responseTimeMs = viewModel.httpResponseTimeMs { - parts.append(("\(responseTimeMs)ms", .secondary)) - } - if let httpProtocol = viewModel.httpProtocol { - parts.append((httpProtocol, .secondary)) - } + init(@ViewBuilder content: () -> Content) { + self.content = content() + } - return parts + var body: some View { + ScrollView(.horizontal) { + VStack(alignment: .leading, spacing: 6) { + content + } + .scrollTargetLayout() + } + .scrollBounceBehavior(.basedOnSize, axes: .horizontal) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(Color(.systemGray6).opacity(0.5)) + .cornerRadius(6) } +} - private var http3AvailabilityNote: String? { - guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else { - return nil +struct LoadingCardView: View { + let text: String + + var body: some View { + CardView { + ProgressView(text) + .frame(maxWidth: .infinity, alignment: .center) } - return "(HTTP/3 available)" } +} + +struct MessageCardView: View { + let text: String + let isError: Bool - private func httpSecurityGradeColor(for grade: String) -> Color { - switch grade { - case "A", "B": - .green - case "C": - .yellow - case "D", "F": - .red - default: - .secondary + var body: some View { + CardView { + MessageRowView(text: text, isError: isError) } } +} + +struct MessageRowView: View { + let text: String + let isError: Bool - private func expiryColor(_ days: Int) -> Color { - if days < 30 { return .red } - if days < 60 { return .yellow } - return .green + var body: some View { + Label(text, systemImage: isError ? "exclamationmark.triangle.fill" : "info.circle") + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(isError ? .red : .secondary) } +} - private func shareResults() { - let text = viewModel.exportText() - let dateFmt = DateFormatter() - dateFmt.dateFormat = "yyyyMMdd_HHmmss" - let timestamp = dateFmt.string(from: Date()) - let filename = "\(timestamp)_domaindigresults.txt" - let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(filename) +struct LabeledValueRow: View { + let row: InfoRowViewData - do { - try text.write(to: tempURL, atomically: true, encoding: .utf8) - } catch { - return + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(row.label) + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) + Text(row.value) + .font(.system(.caption, design: .monospaced)) + .foregroundStyle(ResultColors.color(for: row.tone)) + .textSelection(.enabled) } + } +} - let activityVC = UIActivityViewController(activityItems: [tempURL], applicationActivities: nil) - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let rootVC = windowScene.keyWindow?.rootViewController else { return } - var presenter = rootVC - while let presented = presenter.presentedViewController { - presenter = presented +enum ResultColors { + static func color(for tone: ResultTone) -> Color { + switch tone { + case .primary: + return .primary + case .secondary: + return .secondary + case .success: + return .green + case .warning: + return .yellow + case .failure: + return .red } - activityVC.popoverPresentationController?.sourceView = presenter.view - presenter.present(activityVC, animated: true) } } extension DateFormatter { static let certDate: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .short - return f + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter }() } @@ -948,10 +811,7 @@ private struct SettingsView: View { guard resolverOption == .custom else { return nil } - - return DNSResolverOption.isValidCustomURL(customResolverURL) - ? nil - : "Resolver URL must start with https://" + return DNSResolverOption.isValidCustomURL(customResolverURL) ? nil : "Resolver URL must start with https://" } var body: some View { @@ -981,9 +841,7 @@ private struct SettingsView: View { .onAppear { let currentResolverURL = storedResolverURL.trimmingCharacters(in: .whitespacesAndNewlines) resolverOption = DNSResolverOption.option(for: currentResolverURL) - customResolverURL = resolverOption == .custom - ? currentResolverURL - : DNSResolverOption.defaultURLString + customResolverURL = resolverOption == .custom ? currentResolverURL : DNSResolverOption.defaultURLString } .onChange(of: resolverOption) { _, newValue in guard let presetURL = newValue.urlString else { @@ -993,9 +851,7 @@ private struct SettingsView: View { storedResolverURL = presetURL } .onChange(of: customResolverURL) { _, newValue in - guard resolverOption == .custom else { - return - } + guard resolverOption == .custom else { return } storedResolverURL = newValue.trimmingCharacters(in: .whitespacesAndNewlines) } } diff --git a/DomainDig/DNSLookupService.swift b/DomainDig/DNSLookupService.swift index a59126f..8c8d53c 100644 --- a/DomainDig/DNSLookupService.swift +++ b/DomainDig/DNSLookupService.swift @@ -57,8 +57,6 @@ enum DNSResolverOption: String, CaseIterable, Identifiable { } struct DNSLookupService { - private static let rrsigQueryType = 46 - private static let dnskeyQueryType = 48 private static let internetClass = 1 static func lookup(domain: String, recordType: DNSRecordType) async throws -> [DNSRecord] { @@ -93,7 +91,7 @@ struct DNSLookupService { } } - static func lookupAll(domain: String) async -> [DNSSection] { + static func lookupAll(domain: String) async -> ServiceResult<[DNSSection]> { typealias Result = ( type: DNSRecordType, records: [DNSRecord], @@ -109,8 +107,11 @@ struct DNSLookupService { resolverURLString: resolverURLString ) - return await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in + let sections = await withTaskGroup(of: Result.self, returning: [DNSSection].self) { group in for recordType in DNSRecordType.allCases { + guard recordType != .PTR else { + continue + } let shouldQueryWildcard = wildcardTypes.contains(recordType) group.addTask { var apexRecords: [DNSRecord] = [] @@ -159,6 +160,12 @@ struct DNSLookupService { (order.firstIndex(of: a.recordType) ?? 0) < (order.firstIndex(of: b.recordType) ?? 0) } } + + if sections.contains(where: { !$0.records.isEmpty || !$0.wildcardRecords.isEmpty || $0.error != nil }) { + return .success(sections) + } + + return .empty("No DNS records found") } private static func lookupResponse( @@ -218,11 +225,17 @@ struct DNSLookupService { return response.authenticatedData } - private static func currentResolverURLString() -> String { + static func currentResolverURLString() -> String { let storedValue = UserDefaults.standard.string(forKey: DNSResolverOption.userDefaultsKey) return DNSResolverOption.resolvedURLString(from: storedValue) } + static func currentResolverDisplayName() -> String { + let resolverURLString = currentResolverURLString() + let option = DNSResolverOption.option(for: resolverURLString) + return option == .custom ? resolverURLString : option.title + } + private static func validatedResolverURL(from urlString: String) throws -> URL { guard let url = URL(string: urlString) else { throw URLError(.badURL) diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift index 314703f..638a96b 100644 --- a/DomainDig/DomainViewModel.swift +++ b/DomainDig/DomainViewModel.swift @@ -1,24 +1,162 @@ import Foundation import SwiftUI +enum ResultTone { + case primary + case secondary + case success + case warning + case failure +} + +struct SummaryFieldViewData: Identifiable { + let id = UUID() + let label: String + let value: String + let tone: ResultTone +} + +struct InfoRowViewData: Identifiable { + let id = UUID() + let label: String + let value: String + let tone: ResultTone +} + +struct SectionMessageViewData { + let text: String + let isError: Bool +} + +struct DNSRecordSectionViewData: Identifiable { + let id = UUID() + let title: String + let rows: [InfoRowViewData] + let wildcardRows: [InfoRowViewData] + let wildcardTitle: String? + let message: SectionMessageViewData? +} + +struct EmailRowViewData: Identifiable { + let id = UUID() + let label: String + let status: String + let statusTone: ResultTone + let detail: String + let auxiliaryDetail: String? +} + +struct RedirectHopViewData: Identifiable { + let id = UUID() + let stepLabel: String + let statusCode: String + let url: String + let isFinal: Bool +} + +struct ReachabilityRowViewData: Identifiable { + let id = UUID() + let portLabel: String + let latencyLabel: String + let statusLabel: String + let statusTone: ResultTone +} + +struct PortScanRowViewData: Identifiable { + let id = UUID() + let portLabel: String + let service: String + let statusLabel: String + let statusTone: ResultTone + let banner: String? + let durationLabel: String? +} + +struct LookupSnapshot { + let domain: String + let timestamp: Date + let resolverDisplayName: String + let resolverURLString: String + let totalLookupDurationMs: Int? + let dnsSections: [DNSSection] + let dnsError: String? + let sslInfo: SSLCertificateInfo? + let sslError: String? + let hstsPreloaded: Bool? + let httpHeaders: [HTTPHeader] + let httpSecurityGrade: String? + let httpStatusCode: Int? + let httpResponseTimeMs: Int? + let httpProtocol: String? + let http3Advertised: Bool + let httpHeadersError: String? + let reachabilityResults: [PortReachability] + let reachabilityError: String? + let ipGeolocation: IPGeolocation? + let ipGeolocationError: String? + let emailSecurity: EmailSecurityResult? + let emailSecurityError: String? + let ptrRecord: String? + let ptrError: String? + let redirectChain: [RedirectHop] + let redirectChainError: String? + let portScanResults: [PortScanResult] + let portScanError: String? + let isLive: Bool +} + +extension HistoryEntry { + var snapshot: LookupSnapshot { + LookupSnapshot( + domain: domain, + timestamp: timestamp, + resolverDisplayName: resolverDisplayName, + resolverURLString: resolverURLString, + totalLookupDurationMs: totalLookupDurationMs, + dnsSections: dnsSections, + dnsError: nil, + sslInfo: sslInfo, + sslError: sslError, + hstsPreloaded: hstsPreloaded, + httpHeaders: httpHeaders, + httpSecurityGrade: HTTPSecurityGrade.grade(for: httpHeaders).rawValue, + httpStatusCode: nil, + httpResponseTimeMs: nil, + httpProtocol: nil, + http3Advertised: false, + httpHeadersError: httpHeadersError, + reachabilityResults: reachabilityResults, + reachabilityError: reachabilityError, + ipGeolocation: ipGeolocation, + ipGeolocationError: ipGeolocationError, + emailSecurity: emailSecurity, + emailSecurityError: emailSecurityError, + ptrRecord: ptrRecord, + ptrError: ptrError, + redirectChain: redirectChain, + redirectChainError: redirectChainError, + portScanResults: portScanResults, + portScanError: portScanError, + isLive: false + ) + } +} + @MainActor @Observable final class DomainViewModel { var domain: String = "" - // DNS var dnsSections: [DNSSection] = [] var dnsLoading = false var dnsError: String? - // SSL var sslInfo: SSLCertificateInfo? var sslLoading = false var sslError: String? var hstsPreloaded: Bool? var hstsLoading = false - // HTTP Headers var httpHeaders: [HTTPHeader] = [] var httpSecurityGrade: String? var httpStatusCode: Int? @@ -28,32 +166,26 @@ final class DomainViewModel { var httpHeadersLoading = false var httpHeadersError: String? - // Reachability var reachabilityResults: [PortReachability] = [] var reachabilityLoading = false var reachabilityError: String? - // IP Geolocation var ipGeolocation: IPGeolocation? var ipGeolocationLoading = false var ipGeolocationError: String? - // Email Security var emailSecurity: EmailSecurityResult? var emailSecurityLoading = false var emailSecurityError: String? - // PTR / Reverse DNS var ptrRecord: String? var ptrLoading = false var ptrError: String? - // Redirect Chain var redirectChain: [RedirectHop] = [] var redirectChainLoading = false var redirectChainError: String? - // Port Scan var portScanResults: [PortScanResult] = [] var portScanLoading = false var portScanError: String? @@ -63,368 +195,494 @@ final class DomainViewModel { var hasRun = false private(set) var searchedDomain: String = "" + private(set) var lastLookupDurationMs: Int? - // MARK: - Recent Searches + private var lookupTask: Task? + private var customPortScanTask: Task? + private var activeLookupID = UUID() + private var lookupStartedAt: Date? private static let recentSearchesKey = "recentSearches" private static let maxRecent = 20 - var recentSearches: [String] = UserDefaults.standard.stringArray(forKey: recentSearchesKey) ?? [] - // MARK: - Saved Domains - private static let savedDomainsKey = "savedDomains" - var savedDomains: [String] = UserDefaults.standard.stringArray(forKey: savedDomainsKey) ?? [] - var isCurrentDomainSaved: Bool { - !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) + private static let historyKey = "lookupHistory" + private static let maxHistory = 50 + var history: [HistoryEntry] = { + guard let data = UserDefaults.standard.data(forKey: historyKey), + let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { + return [] + } + return entries + }() + + var trimmedDomain: String { + domain + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/").first ?? "" } - func toggleSavedDomain() { - if isCurrentDomainSaved { - savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() } - } else { - savedDomains.append(searchedDomain) - } - UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + var resultsLoaded: Bool { + hasRun && + !dnsLoading && + !sslLoading && + !hstsLoading && + !httpHeadersLoading && + !reachabilityLoading && + !ipGeolocationLoading && + !emailSecurityLoading && + !ptrLoading && + !redirectChainLoading && + !portScanLoading && + !customPortScanLoading } - func removeSavedDomain(_ domain: String) { - savedDomains.removeAll { $0 == domain } - UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + var isCloudflareProxied: Bool { + httpHeaders.contains { $0.name.lowercased() == "cf-ray" } } - func removeSavedDomains(at offsets: IndexSet) { - savedDomains.remove(atOffsets: offsets) - UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + var isCurrentDomainSaved: Bool { + !searchedDomain.isEmpty && savedDomains.contains(where: { $0.lowercased() == searchedDomain.lowercased() }) } - // MARK: - History + var resolverDisplayName: String { + DNSLookupService.currentResolverDisplayName() + } - private static let historyKey = "lookupHistory" - private static let maxHistory = 50 + var resolverURLString: String { + DNSLookupService.currentResolverURLString() + } - var history: [HistoryEntry] = { - guard let data = UserDefaults.standard.data(forKey: "lookupHistory"), - let entries = try? JSONDecoder().decode([HistoryEntry].self, from: data) else { - return [] + var allPortScanResults: [PortScanResult] { + (portScanResults + customPortResults).sorted { + if $0.kind == $1.kind { + return $0.port < $1.port + } + return $0.kind == .standard } - return entries - }() + } - private func saveHistoryEntry() { - let entry = HistoryEntry( + var currentSnapshot: LookupSnapshot { + LookupSnapshot( domain: searchedDomain, timestamp: Date(), + resolverDisplayName: resolverDisplayName, + resolverURLString: resolverURLString, + totalLookupDurationMs: lastLookupDurationMs, dnsSections: dnsSections, + dnsError: dnsError, sslInfo: sslInfo, + sslError: sslError, + hstsPreloaded: hstsPreloaded, httpHeaders: httpHeaders, + httpSecurityGrade: httpSecurityGrade, + httpStatusCode: httpStatusCode, + httpResponseTimeMs: httpResponseTimeMs, + httpProtocol: httpProtocol, + http3Advertised: http3Advertised, + httpHeadersError: httpHeadersError, reachabilityResults: reachabilityResults, + reachabilityError: reachabilityError, ipGeolocation: ipGeolocation, + ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, - mtaSts: emailSecurity?.mtaSts, + emailSecurityError: emailSecurityError, ptrRecord: ptrRecord, + ptrError: ptrError, redirectChain: redirectChain, - portScanResults: portScanResults, - hstsPreloaded: hstsPreloaded + redirectChainError: redirectChainError, + portScanResults: allPortScanResults, + portScanError: combinedPortScanError, + isLive: true ) - history.insert(entry, at: 0) - if history.count > Self.maxHistory { - history = Array(history.prefix(Self.maxHistory)) - } - if let data = try? JSONEncoder().encode(history) { - UserDefaults.standard.set(data, forKey: Self.historyKey) - } } - func removeHistoryEntries(at offsets: IndexSet) { - history.remove(atOffsets: offsets) - if let data = try? JSONEncoder().encode(history) { - UserDefaults.standard.set(data, forKey: Self.historyKey) - } + var summaryFields: [SummaryFieldViewData] { + Self.summaryFields(from: currentSnapshot) } - // MARK: - Computed + var domainRows: [InfoRowViewData] { + Self.domainRows(from: currentSnapshot) + } - var trimmedDomain: String { - domain - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "https://", with: "") - .replacingOccurrences(of: "http://", with: "") - .components(separatedBy: "/").first ?? "" + var dnsRows: [DNSRecordSectionViewData] { + Self.dnsRows(from: currentSnapshot) } - /// True when all lookups have finished (regardless of success/failure). - var resultsLoaded: Bool { - hasRun && !dnsLoading && !sslLoading && !hstsLoading && !httpHeadersLoading && !reachabilityLoading - && !ipGeolocationLoading && !emailSecurityLoading && !ptrLoading - && !redirectChainLoading && !portScanLoading + var dnssecLabel: String? { + Self.dnssecLabel(from: currentSnapshot) } - /// True when response headers indicate the domain is behind Cloudflare's proxy. - /// Cloudflare injects cf-ray on all proxied (orange-cloud) responses. Grey-cloud - /// (DNS-only) domains won't have this header because traffic doesn't pass through CF's edge. - var isCloudflareProxied: Bool { - httpHeaders.contains { $0.name.lowercased() == "cf-ray" } + var ptrMessage: SectionMessageViewData? { + Self.ptrMessage(from: currentSnapshot) + } + + var webCertificateRows: [InfoRowViewData] { + Self.webCertificateRows(from: currentSnapshot) + } + + var webResponseRows: [InfoRowViewData] { + Self.webResponseRows(from: currentSnapshot) } - // MARK: - Reset + var redirectRows: [RedirectHopViewData] { + Self.redirectRows(from: currentSnapshot) + } + + var emailRows: [EmailRowViewData] { + Self.emailRows(from: currentSnapshot) + } + + var reachabilityRows: [ReachabilityRowViewData] { + Self.reachabilityRows(from: currentSnapshot) + } + + var locationRows: [InfoRowViewData] { + Self.locationRows(from: currentSnapshot) + } + + var standardPortRows: [PortScanRowViewData] { + Self.portRows(from: currentSnapshot, kind: .standard) + } + + var customPortRows: [PortScanRowViewData] { + Self.portRows(from: currentSnapshot, kind: .custom) + } + + var combinedPortScanError: String? { + [portScanError, customPortScanError].compactMap { $0 }.joined(separator: "\n").nilIfEmpty + } + + func toggleSavedDomain() { + if isCurrentDomainSaved { + savedDomains.removeAll { $0.lowercased() == searchedDomain.lowercased() } + } else { + savedDomains.append(searchedDomain) + } + UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + } + + func removeSavedDomains(at offsets: IndexSet) { + savedDomains.remove(atOffsets: offsets) + UserDefaults.standard.set(savedDomains, forKey: Self.savedDomainsKey) + } + + func removeHistoryEntries(at offsets: IndexSet) { + history.remove(atOffsets: offsets) + persistHistory() + } + + func clearRecentSearches() { + recentSearches.removeAll() + UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) + } + + func rerunLookup(from entry: HistoryEntry) { + UserDefaults.standard.set(entry.resolverURLString, forKey: DNSResolverOption.userDefaultsKey) + domain = entry.domain + run() + } func reset() { + lookupTask?.cancel() + customPortScanTask?.cancel() hasRun = false searchedDomain = "" - dnsSections = [] - dnsError = nil - dnsLoading = false - sslInfo = nil - sslError = nil - sslLoading = false - hstsPreloaded = nil - hstsLoading = false - httpHeaders = [] - httpSecurityGrade = nil - httpStatusCode = nil - httpResponseTimeMs = nil - httpProtocol = nil - http3Advertised = false - httpHeadersError = nil - httpHeadersLoading = false - reachabilityResults = [] - reachabilityError = nil - reachabilityLoading = false - ipGeolocation = nil - ipGeolocationError = nil - ipGeolocationLoading = false - emailSecurity = nil - emailSecurityError = nil - emailSecurityLoading = false - ptrRecord = nil - ptrError = nil - ptrLoading = false - redirectChain = [] - redirectChainError = nil - redirectChainLoading = false - portScanResults = [] - portScanError = nil - portScanLoading = false - customPortResults = [] - customPortScanError = nil - customPortScanLoading = false + lastLookupDurationMs = nil + clearLookupState() } - // MARK: - Run - func run() { let target = trimmedDomain guard !target.isEmpty else { return } + lookupTask?.cancel() + customPortScanTask?.cancel() + + let lookupID = UUID() + activeLookupID = lookupID + lookupStartedAt = Date() + lastLookupDurationMs = nil addRecentSearch(target) searchedDomain = target hasRun = true + clearLookupState() + setAllLoadingStates(true) + customPortScanLoading = false - // Reset all state - dnsSections = [] - dnsError = nil - dnsLoading = true - sslInfo = nil - sslError = nil - sslLoading = true - hstsPreloaded = nil - hstsLoading = true - httpHeaders = [] - httpSecurityGrade = nil - httpStatusCode = nil - httpResponseTimeMs = nil - httpProtocol = nil - http3Advertised = false - httpHeadersError = nil - httpHeadersLoading = true - reachabilityResults = [] - reachabilityError = nil - reachabilityLoading = true - ipGeolocation = nil - ipGeolocationError = nil - ipGeolocationLoading = true - emailSecurity = nil - emailSecurityError = nil - emailSecurityLoading = true - ptrRecord = nil - ptrError = nil - ptrLoading = true - redirectChain = [] - redirectChainError = nil - redirectChainLoading = true - portScanResults = [] - portScanError = nil - portScanLoading = true - customPortResults = [] + lookupTask = Task { [weak self] in + guard let self else { return } + await self.performLookup(domain: target, lookupID: lookupID) + } + } + + func runCustomPortScan(ports: [UInt16]) async { + guard !searchedDomain.isEmpty else { + customPortScanError = "Run a domain lookup first" + return + } + + guard !ports.isEmpty else { + customPortScanError = "Enter at least one valid port" + customPortResults = [] + return + } + + customPortScanTask?.cancel() + let domain = searchedDomain + let lookupID = activeLookupID + + customPortScanLoading = true customPortScanError = nil - customPortScanLoading = false + customPortResults = [] - Task { - await withTaskGroup(of: Void.self) { group in - // DNS → chained: email security, PTR, geolocation - group.addTask { @MainActor in - await self.runDNS(domain: target) - // These depend on DNS results and run in parallel after DNS - await withTaskGroup(of: Void.self) { postDNS in - postDNS.addTask { @MainActor in - await self.runEmailSecurity(domain: target) - } - postDNS.addTask { @MainActor in - await self.runReverseDNS() - } - postDNS.addTask { @MainActor in - await self.runIPGeolocation() - } - } - } - group.addTask { @MainActor in - await self.runSSL(domain: target) - } - group.addTask { @MainActor in - await self.runHSTSPreload(domain: target) - } - group.addTask { @MainActor in - await self.runHTTPHeaders(domain: target) - } - group.addTask { @MainActor in - await self.runReachability(domain: target) - } - group.addTask { @MainActor in - await self.runRedirectChain(domain: target) - } - group.addTask { @MainActor in - await self.runPortScan(domain: target) - } - } - // Save history after all lookups complete so the snapshot is complete - saveHistoryEntry() + customPortScanTask = Task { [weak self] in + guard let self else { return } + let result = await PortScanService.scanPorts(domain: domain, ports: ports, timeout: 3.0) + guard !Task.isCancelled, self.isCurrentLookup(lookupID) else { return } + self.applyCustomPortResult(result) } } - // MARK: - Lookup Methods + func exportText() -> String { + Self.formatExportText(from: currentSnapshot) + } + + private func performLookup(domain: String, lookupID: UUID) async { + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.runDNS(domain: domain, lookupID: lookupID) } + group.addTask { await self.runSSL(domain: domain, lookupID: lookupID) } + group.addTask { await self.runHSTSPreload(domain: domain, lookupID: lookupID) } + group.addTask { await self.runHTTPHeaders(domain: domain, lookupID: lookupID) } + group.addTask { await self.runReachability(domain: domain, lookupID: lookupID) } + group.addTask { await self.runRedirectChain(domain: domain, lookupID: lookupID) } + group.addTask { await self.runPortScan(domain: domain, lookupID: lookupID) } + } + + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + + let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] + let primaryIP = primaryIPAddress(from: dnsSections) + + await withTaskGroup(of: Void.self) { group in + group.addTask { await self.runEmailSecurity(domain: domain, txtRecords: txtRecords, lookupID: lookupID) } + if let primaryIP { + group.addTask { await self.runReverseDNS(ip: primaryIP, lookupID: lookupID) } + group.addTask { await self.runIPGeolocation(ip: primaryIP, lookupID: lookupID) } + } else { + group.addTask { await self.finishDependentWithoutPrimaryIP(lookupID: lookupID) } + } + } + + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + lastLookupDurationMs = lookupStartedAt.map { Int(Date().timeIntervalSince($0) * 1000) } + saveHistoryEntry(replaceLatest: false) + } - private func runDNS(domain: String) async { - do { - let sections = await DNSLookupService.lookupAll(domain: domain) + private func runDNS(domain: String, lookupID: UUID) async { + let result = await DNSLookupService.lookupAll(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(sections): dnsSections = sections + dnsError = nil + case let .empty(message): + dnsSections = [] + dnsError = message + case let .error(message): + dnsSections = [] + dnsError = message } dnsLoading = false } - private func runSSL(domain: String) async { - do { - let info = try await SSLCheckService.check(domain: domain) + private func runSSL(domain: String, lookupID: UUID) async { + let result = await SSLCheckService.check(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(info): sslInfo = info - } catch { - sslError = error.localizedDescription + sslError = nil + case let .empty(message): + sslInfo = nil + sslError = message + case let .error(message): + sslInfo = nil + sslError = message } sslLoading = false } - private func runHSTSPreload(domain: String) async { - hstsPreloaded = await SSLCheckService.checkHSTSPreload(domain: domain) + private func runHSTSPreload(domain: String, lookupID: UUID) async { + let result = await SSLCheckService.checkHSTSPreload(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + hstsPreloaded = result hstsLoading = false } - private func runHTTPHeaders(domain: String) async { - do { - let result = try await HTTPHeadersService.fetch(domain: domain) - httpHeaders = result.headers - httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue - httpStatusCode = result.statusCode - httpResponseTimeMs = result.responseTimeMs - httpProtocol = result.httpProtocol - http3Advertised = result.http3Advertised - } catch { - httpHeadersError = error.localizedDescription + private func runHTTPHeaders(domain: String, lookupID: UUID) async { + let result = await HTTPHeadersService.fetch(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(headersResult): + httpHeaders = headersResult.headers + httpSecurityGrade = HTTPSecurityGrade.grade(for: headersResult.headers).rawValue + httpStatusCode = headersResult.statusCode + httpResponseTimeMs = headersResult.responseTimeMs + httpProtocol = headersResult.httpProtocol + http3Advertised = headersResult.http3Advertised + httpHeadersError = nil + case let .empty(message): + httpHeaders = [] + httpSecurityGrade = nil + httpStatusCode = nil + httpResponseTimeMs = nil + httpProtocol = nil + http3Advertised = false + httpHeadersError = message + case let .error(message): + httpHeaders = [] + httpSecurityGrade = nil + httpStatusCode = nil + httpResponseTimeMs = nil + httpProtocol = nil + http3Advertised = false + httpHeadersError = message } httpHeadersLoading = false } - private func runReachability(domain: String) async { - let results = await ReachabilityService.checkAll(domain: domain) - reachabilityResults = results - reachabilityLoading = false - } - - private func runIPGeolocation() async { - // Find the first A record IP - guard let aSection = dnsSections.first(where: { $0.recordType == .A }), - let firstIP = aSection.records.first?.value else { - ipGeolocationError = "No A record available" - ipGeolocationLoading = false - return + private func runReachability(domain: String, lookupID: UUID) async { + let result = await ReachabilityService.checkAll(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(results): + reachabilityResults = results + reachabilityError = nil + case let .empty(message): + reachabilityResults = [] + reachabilityError = message + case let .error(message): + reachabilityResults = [] + reachabilityError = message } - do { - let geo = try await IPGeolocationService.lookup(ip: firstIP) - ipGeolocation = geo - } catch { - ipGeolocationError = error.localizedDescription - } - ipGeolocationLoading = false + reachabilityLoading = false } - private func runEmailSecurity(domain: String) async { - // Extract TXT records from already-fetched DNS sections - let txtRecords = dnsSections.first(where: { $0.recordType == .TXT })?.records ?? [] + private func runEmailSecurity(domain: String, txtRecords: [DNSRecord], lookupID: UUID) async { let result = await EmailSecurityService.analyze(domain: domain, txtRecords: txtRecords) - emailSecurity = result + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(emailResult): + emailSecurity = emailResult + emailSecurityError = nil + case let .empty(message): + emailSecurity = nil + emailSecurityError = message + case let .error(message): + emailSecurity = nil + emailSecurityError = message + } emailSecurityLoading = false } - private func runReverseDNS() async { - guard let aSection = dnsSections.first(where: { $0.recordType == .A }), - let firstIP = aSection.records.first?.value else { - ptrError = "No A record available" - ptrLoading = false - return - } - let result = await ReverseDNSService.lookup(ip: firstIP) - ptrRecord = result - if result == nil { - ptrError = "No PTR record found" + private func runReverseDNS(ip: String, lookupID: UUID) async { + let result = await ReverseDNSService.lookup(ip: ip, resolverURLString: resolverURLString) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(record): + ptrRecord = record + ptrError = nil + case let .empty(message): + ptrRecord = nil + ptrError = message + case let .error(message): + ptrRecord = nil + ptrError = message } ptrLoading = false } - private func runRedirectChain(domain: String) async { - do { - let hops = try await RedirectChainService.trace(domain: domain) + private func runRedirectChain(domain: String, lookupID: UUID) async { + let result = await RedirectChainService.trace(domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(hops): redirectChain = hops - } catch { - redirectChainError = error.localizedDescription + redirectChainError = nil + case let .empty(message): + redirectChain = [] + redirectChainError = message + case let .error(message): + redirectChain = [] + redirectChainError = message } redirectChainLoading = false } - private func runPortScan(domain: String) async { - let results = await PortScanService.scanAll(domain: domain) - let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain) - portScanResults = enrichedResults + private func runPortScan(domain: String, lookupID: UUID) async { + let result = await PortScanService.scanAll(domain: domain) + switch result { + case let .success(results): + let enrichedResults = await enrichOpenPortBanners(in: results, domain: domain) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + portScanResults = enrichedResults + portScanError = nil + case let .empty(message): + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + portScanResults = [] + portScanError = message + case let .error(message): + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + portScanResults = [] + portScanError = message + } portScanLoading = false } - func runCustomPortScan(ports: [UInt16]) async { - guard !searchedDomain.isEmpty else { - customPortScanError = "Run a domain lookup first" - return + private func runIPGeolocation(ip: String, lookupID: UUID) async { + let result = await IPGeolocationService.lookup(ip: ip) + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + switch result { + case let .success(geolocation): + ipGeolocation = geolocation + ipGeolocationError = nil + case let .empty(message): + ipGeolocation = nil + ipGeolocationError = message + case let .error(message): + ipGeolocation = nil + ipGeolocationError = message } + ipGeolocationLoading = false + } - guard !ports.isEmpty else { - customPortScanError = "Enter at least one valid port" + private func finishDependentWithoutPrimaryIP(lookupID: UUID) async { + guard !Task.isCancelled, isCurrentLookup(lookupID) else { return } + ptrLoading = false + ptrError = "No A record available" + ipGeolocationLoading = false + ipGeolocationError = "No A record available" + } + + private func applyCustomPortResult(_ result: ServiceResult<[PortScanResult]>) { + switch result { + case let .success(results): + customPortResults = results + customPortScanError = nil + saveHistoryEntry(replaceLatest: true) + case let .empty(message): customPortResults = [] - return + customPortScanError = message + case let .error(message): + customPortResults = [] + customPortScanError = message } - - customPortScanLoading = true - customPortScanError = nil - customPortResults = [] - - let results = await PortScanService.scanPorts(domain: searchedDomain, ports: ports, timeout: 3.0) - customPortResults = results customPortScanLoading = false } @@ -453,271 +711,502 @@ final class DomainViewModel { } } - // MARK: - Export - - func exportText() -> String { - return Self.formatExportText( + private func saveHistoryEntry(replaceLatest: Bool) { + guard !searchedDomain.isEmpty else { return } + let entry = HistoryEntry( domain: searchedDomain, - date: Date(), + timestamp: Date(), dnsSections: dnsSections, sslInfo: sslInfo, - sslError: sslError, - hstsPreloaded: hstsPreloaded, httpHeaders: httpHeaders, - httpSecurityGrade: httpSecurityGrade, - httpStatusCode: httpStatusCode, - httpResponseTimeMs: httpResponseTimeMs, - httpProtocol: httpProtocol, - http3Advertised: http3Advertised, - httpHeadersError: httpHeadersError, reachabilityResults: reachabilityResults, ipGeolocation: ipGeolocation, - ipGeolocationError: ipGeolocationError, emailSecurity: emailSecurity, + mtaSts: emailSecurity?.mtaSts, ptrRecord: ptrRecord, redirectChain: redirectChain, - portScanResults: portScanResults + portScanResults: allPortScanResults, + hstsPreloaded: hstsPreloaded, + resolverDisplayName: resolverDisplayName, + resolverURLString: resolverURLString, + totalLookupDurationMs: lastLookupDurationMs, + sslError: sslError, + httpHeadersError: httpHeadersError, + reachabilityError: reachabilityError, + ipGeolocationError: ipGeolocationError, + emailSecurityError: emailSecurityError, + ptrError: ptrError, + redirectChainError: redirectChainError, + portScanError: combinedPortScanError ) + + if replaceLatest, !history.isEmpty, history[0].domain.caseInsensitiveCompare(searchedDomain) == .orderedSame { + history[0] = entry + } else { + history.insert(entry, at: 0) + if history.count > Self.maxHistory { + history = Array(history.prefix(Self.maxHistory)) + } + } + persistHistory() + } + + private func persistHistory() { + if let data = try? JSONEncoder().encode(history) { + UserDefaults.standard.set(data, forKey: Self.historyKey) + } + } + + private func addRecentSearch(_ domain: String) { + recentSearches.removeAll { $0.lowercased() == domain.lowercased() } + recentSearches.insert(domain, at: 0) + if recentSearches.count > Self.maxRecent { + recentSearches = Array(recentSearches.prefix(Self.maxRecent)) + } + UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) + } + + private func clearLookupState() { + dnsSections = [] + dnsError = nil + dnsLoading = false + sslInfo = nil + sslError = nil + sslLoading = false + hstsPreloaded = nil + hstsLoading = false + httpHeaders = [] + httpSecurityGrade = nil + httpStatusCode = nil + httpResponseTimeMs = nil + httpProtocol = nil + http3Advertised = false + httpHeadersError = nil + httpHeadersLoading = false + reachabilityResults = [] + reachabilityError = nil + reachabilityLoading = false + ipGeolocation = nil + ipGeolocationError = nil + ipGeolocationLoading = false + emailSecurity = nil + emailSecurityError = nil + emailSecurityLoading = false + ptrRecord = nil + ptrError = nil + ptrLoading = false + redirectChain = [] + redirectChainError = nil + redirectChainLoading = false + portScanResults = [] + portScanError = nil + portScanLoading = false + customPortResults = [] + customPortScanError = nil + customPortScanLoading = false + } + + private func setAllLoadingStates(_ loading: Bool) { + dnsLoading = loading + sslLoading = loading + hstsLoading = loading + httpHeadersLoading = loading + reachabilityLoading = loading + ipGeolocationLoading = loading + emailSecurityLoading = loading + ptrLoading = loading + redirectChainLoading = loading + portScanLoading = loading + } + + private func primaryIPAddress(from sections: [DNSSection]) -> String? { + sections.first(where: { $0.recordType == .A })?.records.first?.value + } + + private func isCurrentLookup(_ lookupID: UUID) -> Bool { + activeLookupID == lookupID + } + + static func summaryFields(from snapshot: LookupSnapshot) -> [SummaryFieldViewData] { + [ + SummaryFieldViewData(label: "Domain", value: snapshot.domain.nonEmpty ?? "Unavailable", tone: .primary), + SummaryFieldViewData(label: "Primary IP", value: primaryIPAddress(from: snapshot) ?? "Unavailable", tone: .primary), + SummaryFieldViewData(label: "HTTPS", value: httpsSummary(from: snapshot), tone: httpsSummaryTone(from: snapshot)), + SummaryFieldViewData(label: "Redirect", value: finalRedirectTarget(from: snapshot) ?? "Unavailable", tone: .secondary), + SummaryFieldViewData(label: "Email", value: emailSummary(from: snapshot), tone: .secondary) + ] + } + + static func domainRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { + [ + InfoRowViewData(label: "Domain", value: snapshot.domain, tone: .primary), + InfoRowViewData(label: "Resolver", value: snapshot.resolverDisplayName, tone: .secondary), + InfoRowViewData(label: snapshot.isLive ? "Result" : "Snapshot", value: snapshot.isLive ? "Live" : "Snapshot", tone: snapshot.isLive ? .success : .warning), + InfoRowViewData(label: "Lookup Duration", value: durationLabel(snapshot.totalLookupDurationMs), tone: .secondary) + ] + } + + static func dnsRows(from snapshot: LookupSnapshot) -> [DNSRecordSectionViewData] { + snapshot.dnsSections.map { section in + DNSRecordSectionViewData( + title: section.recordType.rawValue, + rows: section.records.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, + wildcardRows: section.wildcardRecords.map { InfoRowViewData(label: "TTL \($0.ttl)", value: $0.value, tone: .primary) }, + wildcardTitle: section.wildcardRecords.isEmpty ? nil : "*.\(snapshot.domain)", + message: section.error.map { SectionMessageViewData(text: $0, isError: true) } ?? + ((section.records.isEmpty && section.wildcardRecords.isEmpty) ? SectionMessageViewData(text: "No records found", isError: false) : nil) + ) + } + } + + static func dnssecLabel(from snapshot: LookupSnapshot) -> String? { + guard let signed = snapshot.dnsSections.compactMap(\.dnssecSigned).first else { return nil } + return "Resolver-reported DNSSEC (not full validation): \(signed ? "Yes" : "No")" + } + + static func ptrMessage(from snapshot: LookupSnapshot) -> SectionMessageViewData? { + if let ptrRecord = snapshot.ptrRecord { + return SectionMessageViewData(text: ptrRecord, isError: false) + } + if let ptrError = snapshot.ptrError { + return SectionMessageViewData(text: ptrError, isError: ptrError != "No A record available" && ptrError != "No PTR record found") + } + return nil + } + + static func webCertificateRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { + guard let sslInfo = snapshot.sslInfo else { return [] } + var rows = [ + InfoRowViewData(label: "Common Name", value: sslInfo.commonName, tone: .primary), + InfoRowViewData(label: "Issuer", value: sslInfo.issuer, tone: .primary), + InfoRowViewData(label: "Valid From", value: DateFormatter.certDate.string(from: sslInfo.validFrom), tone: .secondary), + InfoRowViewData(label: "Valid Until", value: DateFormatter.certDate.string(from: sslInfo.validUntil), tone: .secondary), + InfoRowViewData(label: "Days Until Expiry", value: "\(sslInfo.daysUntilExpiry)", tone: sslInfo.daysUntilExpiry < 30 ? .failure : (sslInfo.daysUntilExpiry < 60 ? .warning : .success)), + InfoRowViewData(label: "Chain Depth", value: "\(sslInfo.chainDepth)", tone: .secondary) + ] + if let tlsVersion = sslInfo.tlsVersion { + rows.append(InfoRowViewData(label: "TLS Version", value: tlsVersion, tone: .secondary)) + } + if let cipherSuite = sslInfo.cipherSuite { + rows.append(InfoRowViewData(label: "Cipher Suite", value: cipherSuite, tone: .secondary)) + } + if let hstsPreloaded = snapshot.hstsPreloaded { + rows.append(InfoRowViewData(label: "HSTS Preload", value: hstsPreloaded ? "Preloaded" : "Not preloaded", tone: hstsPreloaded ? .success : .secondary)) + } + return rows + } + + static func webResponseRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { + var rows: [InfoRowViewData] = [] + if let httpStatusCode = snapshot.httpStatusCode { + rows.append(InfoRowViewData(label: "Status", value: "\(httpStatusCode)", tone: .primary)) + } + if let httpResponseTimeMs = snapshot.httpResponseTimeMs { + rows.append(InfoRowViewData(label: "Response Time", value: "\(httpResponseTimeMs) ms", tone: .secondary)) + } + if let httpProtocol = snapshot.httpProtocol { + rows.append(InfoRowViewData(label: "Protocol", value: httpProtocol, tone: .secondary)) + } + if let httpSecurityGrade = snapshot.httpSecurityGrade { + rows.append(InfoRowViewData(label: "Security Grade", value: httpSecurityGrade, tone: securityGradeTone(httpSecurityGrade))) + } + if snapshot.http3Advertised { + rows.append(InfoRowViewData(label: "HTTP/3", value: "Advertised", tone: .secondary)) + } + return rows + } + + static func redirectRows(from snapshot: LookupSnapshot) -> [RedirectHopViewData] { + snapshot.redirectChain.map { + RedirectHopViewData( + stepLabel: "\($0.stepNumber)", + statusCode: "\($0.statusCode)", + url: $0.url, + isFinal: $0.isFinal + ) + } } - static func formatExportText( - domain: String, - date: Date, - dnsSections: [DNSSection], - sslInfo: SSLCertificateInfo?, - sslError: String? = nil, - hstsPreloaded: Bool? = nil, - httpHeaders: [HTTPHeader], - httpSecurityGrade: String? = nil, - httpStatusCode: Int? = nil, - httpResponseTimeMs: Int? = nil, - httpProtocol: String? = nil, - http3Advertised: Bool = false, - httpHeadersError: String? = nil, - reachabilityResults: [PortReachability], - ipGeolocation: IPGeolocation?, - ipGeolocationError: String? = nil, - emailSecurity: EmailSecurityResult? = nil, - ptrRecord: String? = nil, - redirectChain: [RedirectHop] = [], - portScanResults: [PortScanResult] = [] - ) -> String { - let dateFmt = DateFormatter() - dateFmt.dateFormat = "yyyy-MM-dd HH:mm" + static func emailRows(from snapshot: LookupSnapshot) -> [EmailRowViewData] { + guard let emailSecurity = snapshot.emailSecurity else { return [] } + return [ + EmailRowViewData(label: "SPF", status: emailSecurity.spf.found ? "Present" : "Missing", statusTone: emailSecurity.spf.found ? .success : .warning, detail: emailSecurity.spf.value ?? "No record found", auxiliaryDetail: nil), + EmailRowViewData(label: "DMARC", status: emailSecurity.dmarc.found ? "Present" : "Missing", statusTone: emailSecurity.dmarc.found ? .success : .warning, detail: emailSecurity.dmarc.value ?? "No record found", auxiliaryDetail: nil), + 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)" }), + 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), + EmailRowViewData(label: "BIMI", status: emailSecurity.bimi.found ? "Present" : "Missing", statusTone: emailSecurity.bimi.found ? .success : .warning, detail: emailSecurity.bimi.value ?? "No record found", auxiliaryDetail: nil) + ] + } + + static func reachabilityRows(from snapshot: LookupSnapshot) -> [ReachabilityRowViewData] { + snapshot.reachabilityResults.map { + ReachabilityRowViewData( + portLabel: "Port \($0.port)", + latencyLabel: $0.latencyMs.map { "\($0) ms" } ?? "—", + statusLabel: $0.reachable ? "Reachable" : "Unreachable", + statusTone: $0.reachable ? .success : .failure + ) + } + } + + static func locationRows(from snapshot: LookupSnapshot) -> [InfoRowViewData] { + guard let ipGeolocation = snapshot.ipGeolocation else { return [] } + var rows = [InfoRowViewData(label: "IP", value: ipGeolocation.ip, tone: .primary)] + if let org = ipGeolocation.org { + rows.append(InfoRowViewData(label: "Org / ISP", value: org, tone: .secondary)) + } + let location = [ipGeolocation.city, ipGeolocation.region, ipGeolocation.country_name].compactMap { $0 }.joined(separator: ", ") + if !location.isEmpty { + rows.append(InfoRowViewData(label: "Location", value: location, tone: .secondary)) + } + if let latitude = ipGeolocation.latitude, let longitude = ipGeolocation.longitude { + rows.append(InfoRowViewData(label: "Coordinates", value: "\(latitude), \(longitude)", tone: .secondary)) + } + return rows + } + + static func portRows(from snapshot: LookupSnapshot, kind: PortScanKind) -> [PortScanRowViewData] { + snapshot.portScanResults + .filter { $0.kind == kind } + .map { + PortScanRowViewData( + portLabel: "\($0.port)", + service: $0.service, + statusLabel: $0.open ? "Open" : "Closed", + statusTone: $0.open ? .success : .secondary, + banner: $0.banner, + durationLabel: $0.durationMs.map { "\($0) ms" } + ) + } + } + + static func formatExportText(from snapshot: LookupSnapshot) -> String { + let exportDateFormatter = DateFormatter() + exportDateFormatter.dateFormat = "yyyy-MM-dd HH:mm" var lines: [String] = [ "DomainDig Export", - "Domain: \(domain)", - "Date: \(dateFmt.string(from: date))", + "Domain: \(snapshot.domain)", + "Date: \(exportDateFormatter.string(from: snapshot.timestamp))", + "Mode: \(snapshot.isLive ? "Live" : "Snapshot")", + "Resolver: \(snapshot.resolverDisplayName)", + "Lookup Duration: \(durationLabel(snapshot.totalLookupDurationMs))" ] - // Reachability - if !reachabilityResults.isEmpty { + func appendSection(_ title: String, body: () -> Void) { lines.append("") - lines.append("Reachability") - lines.append("------------") - for result in reachabilityResults { - if result.reachable, let ms = result.latencyMs { - lines.append(" Port \(result.port) \(ms)ms Reachable") - } else { - lines.append(" Port \(result.port) — Unreachable") - } + lines.append(title) + lines.append(String(repeating: "-", count: title.count)) + body() + } + + appendSection("Summary") { + for item in summaryFields(from: snapshot) { + lines.append(" \(item.label): \(item.value)") } } - // Redirect Chain - if !redirectChain.isEmpty { - lines.append("") - lines.append("Redirect Chain") - lines.append("--------------") - if redirectChain.count == 1 && redirectChain[0].isFinal && !(300...399).contains(redirectChain[0].statusCode) { - lines.append(" No redirects — direct connection") - } else { - for hop in redirectChain { - let final = hop.isFinal ? " (final)" : "" - lines.append(" \(hop.stepNumber) \(hop.statusCode) \(hop.url)\(final)") - } + appendSection("Domain") { + for row in domainRows(from: snapshot) { + lines.append(" \(row.label): \(row.value)") } } - // DNS - lines.append("") - lines.append("DNS Records") - lines.append("-----------") - for section in dnsSections { - lines.append(section.recordType.rawValue) - if let error = section.error { - lines.append(" Error: \(error)") - } else if section.records.isEmpty { - lines.append(" No records found") - } else { - for record in section.records { - lines.append(" \(record.value) TTL \(record.ttl)") - } + appendSection("DNS") { + if let dnsError = snapshot.dnsError { + lines.append(" Error: \(dnsError)") + } + if let dnssecLabel = dnssecLabel(from: snapshot) { + lines.append(" \(dnssecLabel)") } - if !section.wildcardRecords.isEmpty { - lines.append("*.\(domain)") - for record in section.wildcardRecords { - lines.append(" \(record.value) TTL \(record.ttl)") + for section in dnsRows(from: snapshot) { + lines.append(" \(section.title)") + if let message = section.message { + lines.append(" \(message.isError ? "Error" : "Info"): \(message.text)") } + for row in section.rows { + lines.append(" \(row.value) (\(row.label))") + } + if let wildcardTitle = section.wildcardTitle { + lines.append(" \(wildcardTitle)") + for row in section.wildcardRows { + lines.append(" \(row.value) (\(row.label))") + } + } + } + if let ptrRecord = snapshot.ptrRecord { + lines.append(" PTR: \(ptrRecord)") + } else if let ptrError = snapshot.ptrError { + lines.append(" PTR Error: \(ptrError)") } } - // PTR - if let ptr = ptrRecord { - lines.append("PTR (Reverse DNS)") - lines.append(" \(ptr)") - } + appendSection("Web") { + if let sslError = snapshot.sslError { + lines.append(" TLS Error: \(sslError)") + } else { + for row in webCertificateRows(from: snapshot) { + lines.append(" \(row.label): \(row.value)") + } + } - // Email Security - if let email = emailSecurity { - lines.append("") - lines.append("Email Security") - lines.append("--------------") - lines.append(" SPF: \(email.spf.found ? "✓" : "✗") \(email.spf.value ?? "No record found")") - lines.append(" DMARC: \(email.dmarc.found ? "✓" : "✗") \(email.dmarc.value ?? "No record found")") - let dkimValue = if let selector = email.dkim.matchedSelector, - let value = email.dkim.value { - "\(value) (selector: \(selector))" + if let httpHeadersError = snapshot.httpHeadersError { + lines.append(" Headers Error: \(httpHeadersError)") } else { - email.dkim.value ?? "No record found" + for row in webResponseRows(from: snapshot) { + lines.append(" \(row.label): \(row.value)") + } + if snapshot.httpHeaders.isEmpty { + lines.append(" Headers: No headers returned") + } else { + lines.append(" Headers:") + for header in snapshot.httpHeaders { + lines.append(" \(header.name): \(header.value)") + } + } } - lines.append(" DKIM: \(email.dkim.found ? "✓" : "✗") \(dkimValue)") - let mtaDescription = if let mode = email.mtaSts?.policyMode { - "mode: \(mode)" - } else if email.mtaSts?.txtFound == true { - "Policy unavailable" + + if let redirectChainError = snapshot.redirectChainError { + lines.append(" Redirect Error: \(redirectChainError)") + } else if snapshot.redirectChain.isEmpty { + lines.append(" Redirects: No redirect data available") } else { - "No record found" + lines.append(" Redirects:") + for hop in redirectRows(from: snapshot) { + lines.append(" \(hop.stepLabel). \(hop.statusCode) \(hop.url)\(hop.isFinal ? " (final)" : "")") + } } - lines.append(" MTA-STS: \(email.mtaSts?.txtFound == true ? "✓" : "✗") \(mtaDescription)") - lines.append(" BIMI: \(email.bimi.found ? "✓" : "✗") \(email.bimi.value ?? "No record found")") } - // SSL - if let info = sslInfo { - let certDateFmt = DateFormatter() - certDateFmt.dateStyle = .medium - certDateFmt.timeStyle = .none - - lines.append("") - lines.append("SSL / TLS Certificate") - lines.append("---------------------") - lines.append("Common Name: \(info.commonName)") - lines.append("Issuer: \(info.issuer)") - lines.append("SANs: \(info.subjectAltNames.joined(separator: ", "))") - lines.append("Valid From: \(certDateFmt.string(from: info.validFrom))") - lines.append("Valid Until: \(certDateFmt.string(from: info.validUntil))") - lines.append("Days Until Expiry: \(info.daysUntilExpiry)") - lines.append("Chain Depth: \(info.chainDepth)") - if let tlsVersion = info.tlsVersion { - lines.append("TLS Version: \(tlsVersion)") - } - if let cipherSuite = info.cipherSuite { - lines.append("Cipher Suite: \(cipherSuite)") - } - if let hstsPreloaded { - lines.append("HSTS Preload: \(hstsPreloaded ? "Preloaded" : "Not preloaded")") - } - if !info.chain.isEmpty { - lines.append("Certificate Chain:") - for certificate in info.chain { - lines.append(" Subject: \(certificate.subject)") - lines.append(" Issuer: \(certificate.issuer)") + appendSection("Email") { + if let emailSecurityError = snapshot.emailSecurityError { + lines.append(" Error: \(emailSecurityError)") + } else if emailRows(from: snapshot).isEmpty { + lines.append(" No email security records found") + } else { + for row in emailRows(from: snapshot) { + lines.append(" \(row.label): \(row.status)") + lines.append(" \(row.detail)") + if let auxiliaryDetail = row.auxiliaryDetail { + lines.append(" \(auxiliaryDetail)") + } } } - } else if let error = sslError { - lines.append("") - lines.append("SSL / TLS Certificate") - lines.append("---------------------") - lines.append("Error: \(error)") } - // HTTP Headers - if !httpHeaders.isEmpty { - lines.append("") - lines.append("HTTP Headers") - lines.append("------------") - for header in httpHeaders { - lines.append(" \(header.name): \(header.value)") - } - if let httpSecurityGrade { - lines.append("Grade: \(httpSecurityGrade)") - } - if let httpStatusCode { - lines.append("Status: \(httpStatusCode)") - } - if let httpResponseTimeMs { - lines.append("Response Time: \(httpResponseTimeMs)ms") - } - if let httpProtocol { - lines.append("Protocol: \(httpProtocol)") + appendSection("Network") { + if let reachabilityError = snapshot.reachabilityError { + lines.append(" Reachability Error: \(reachabilityError)") + } else if reachabilityRows(from: snapshot).isEmpty { + lines.append(" Reachability: No results") + } else { + lines.append(" Reachability:") + for row in reachabilityRows(from: snapshot) { + lines.append(" \(row.portLabel): \(row.statusLabel) \(row.latencyLabel)") + } } - if http3Advertised { - lines.append("HTTP/3 Advertised: Yes") + + if let ipGeolocationError = snapshot.ipGeolocationError, snapshot.ipGeolocation == nil { + lines.append(" Location Error: \(ipGeolocationError)") + } else if locationRows(from: snapshot).isEmpty { + lines.append(" Location: No data") + } else { + lines.append(" Location:") + for row in locationRows(from: snapshot) { + lines.append(" \(row.label): \(row.value)") + } } - } else if let error = httpHeadersError { - lines.append("") - lines.append("HTTP Headers") - lines.append("------------") - lines.append("Error: \(error)") - } - // IP Geolocation - if let geo = ipGeolocation { - lines.append("") - lines.append("IP Location") - lines.append("-----------") - lines.append("IP: \(geo.ip)") - if let org = geo.org { lines.append("Org: \(org)") } - let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") - if !location.isEmpty { lines.append("Location: \(location)") } - if let lat = geo.latitude, let lon = geo.longitude { - lines.append("Coordinates: \(lat), \(lon)") + if let portScanError = snapshot.portScanError, snapshot.portScanResults.isEmpty { + lines.append(" Port Scan Error: \(portScanError)") } - } else if let error = ipGeolocationError, error != "No A record available" { - lines.append("") - lines.append("IP Location") - lines.append("-----------") - lines.append("Error: \(error)") - } - // Open Ports - if !portScanResults.isEmpty { - lines.append("") - lines.append("Open Ports") - lines.append("----------") - let openPorts = portScanResults.filter { $0.open } - if openPorts.isEmpty { - lines.append(" No open ports detected") + lines.append(" Standard Ports:") + let standardRows = portRows(from: snapshot, kind: .standard) + if standardRows.isEmpty { + lines.append(" No results") } else { - for port in openPorts { - let bannerSuffix = port.banner.map { " \($0)" } ?? "" - lines.append(" \(port.port) \(port.service)\(bannerSuffix)") + for row in standardRows { + lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")") + if let banner = row.banner { + lines.append(" Banner: \(banner)") + } } } - let closedPorts = portScanResults.filter { !$0.open } - if !closedPorts.isEmpty { - lines.append("Closed: \(closedPorts.map { "\($0.port)" }.joined(separator: ", "))") + + lines.append(" Custom Ports:") + let customRows = portRows(from: snapshot, kind: .custom) + if customRows.isEmpty { + lines.append(" No results") + } else { + for row in customRows { + lines.append(" \(row.portLabel) \(row.service): \(row.statusLabel)\(row.durationLabel.map { " \($0)" } ?? "")") + if let banner = row.banner { + lines.append(" Banner: \(banner)") + } + } } } return lines.joined(separator: "\n") } - // MARK: - Recent Searches + private static func primaryIPAddress(from snapshot: LookupSnapshot) -> String? { + snapshot.dnsSections.first(where: { $0.recordType == .A })?.records.first?.value + } - private func addRecentSearch(_ domain: String) { - recentSearches.removeAll { $0.lowercased() == domain.lowercased() } - recentSearches.insert(domain, at: 0) - if recentSearches.count > Self.maxRecent { - recentSearches = Array(recentSearches.prefix(Self.maxRecent)) + private static func finalRedirectTarget(from snapshot: LookupSnapshot) -> String? { + snapshot.redirectChain.last?.url + } + + private static func httpsSummary(from snapshot: LookupSnapshot) -> String { + if snapshot.sslInfo != nil { + return "Valid" } - UserDefaults.standard.set(recentSearches, forKey: Self.recentSearchesKey) + if let sslError = snapshot.sslError { + return sslError.localizedCaseInsensitiveContains("certificate") ? "Invalid" : "Failed" + } + return "Unavailable" } - func clearRecentSearches() { - recentSearches.removeAll() - UserDefaults.standard.removeObject(forKey: Self.recentSearchesKey) + private static func httpsSummaryTone(from snapshot: LookupSnapshot) -> ResultTone { + if snapshot.sslInfo != nil { + return .success + } + return snapshot.sslError == nil ? .secondary : .failure + } + + private static func emailSummary(from snapshot: LookupSnapshot) -> String { + guard let emailSecurity = snapshot.emailSecurity else { + return snapshot.emailSecurityError ?? "Unavailable" + } + return "SPF \(emailSecurity.spf.found ? "Yes" : "No") / DMARC \(emailSecurity.dmarc.found ? "Yes" : "No")" + } + + private static func securityGradeTone(_ grade: String) -> ResultTone { + switch grade { + case "A", "B": + return .success + case "C": + return .warning + case "D", "F": + return .failure + default: + return .secondary + } + } + + private static func durationLabel(_ durationMs: Int?) -> String { + durationMs.map { "\($0) ms" } ?? "Unavailable" + } +} + +private extension String { + var nonEmpty: String? { + isEmpty ? nil : self + } + + var nilIfEmpty: String? { + isEmpty ? nil : self } } diff --git a/DomainDig/EmailSecurityService.swift b/DomainDig/EmailSecurityService.swift index 50e5f73..c80d0f7 100644 --- a/DomainDig/EmailSecurityService.swift +++ b/DomainDig/EmailSecurityService.swift @@ -8,7 +8,7 @@ struct EmailSecurityService { /// Analyze email security records. SPF is parsed from existing TXT records; /// DMARC and DKIM require additional DoH queries. - static func analyze(domain: String, txtRecords: [DNSRecord]) async -> EmailSecurityResult { + static func analyze(domain: String, txtRecords: [DNSRecord]) async -> ServiceResult { // SPF: prefer the already-fetched apex TXT records, but fall back to a direct lookup // in case the earlier DNS section missed or normalized the record differently. let localSPFRecord = txtRecords.first(where: { isMatchingTXTRecord($0.value, prefix: "v=spf1") })?.value @@ -49,13 +49,16 @@ struct EmailSecurityService { value: bimiValue ) - return EmailSecurityResult( + let result = EmailSecurityResult( spf: spf, dmarc: dmarc, dkim: dkim, bimi: bimi, mtaSts: mtaSts ) + + let hasAnyRecord = result.spf.found || result.dmarc.found || result.dkim.found || result.bimi.found || result.mtaSts?.txtFound == true + return hasAnyRecord ? .success(result) : .empty("No email security records found") } /// Query a TXT record for the given subdomain via DoH. diff --git a/DomainDig/HTTPHeadersService.swift b/DomainDig/HTTPHeadersService.swift index 51e7b5b..4bf175a 100644 --- a/DomainDig/HTTPHeadersService.swift +++ b/DomainDig/HTTPHeadersService.swift @@ -35,41 +35,46 @@ struct HTTPHeadersResult { } struct HTTPHeadersService { - static func fetch(domain: String) async throws -> HTTPHeadersResult { + static func fetch(domain: String) async -> ServiceResult { let url = URL(string: "https://\(domain)")! var request = URLRequest(url: url, timeoutInterval: 10) request.httpMethod = "HEAD" let metricsDelegate = TaskMetricsDelegate() let startTime = Date() - let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate) - let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000)) - - guard let httpResponse = response as? HTTPURLResponse else { - throw URLError(.badServerResponse) - } - - let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in - guard let name = entry.key as? String, - let value = entry.value as? String else { return nil } - return HTTPHeader(name: name, value: value) + do { + let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate) + let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000)) + + guard let httpResponse = response as? HTTPURLResponse else { + return .error(URLError(.badServerResponse).localizedDescription) + } + + let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in + guard let name = entry.key as? String, + let value = entry.value as? String else { return nil } + return HTTPHeader(name: name, value: value) + } + .sorted { $0.name.lowercased() < $1.name.lowercased() } + + let networkProtocolName = metricsDelegate.metrics?.transactionMetrics + .compactMap { $0.networkProtocolName } + .last + let detectedProtocol = protocolLabel(for: networkProtocolName) + let altSvcValue = headerValue(named: "alt-svc", in: httpResponse) + let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true + let result = HTTPHeadersResult( + headers: headers, + statusCode: httpResponse.statusCode, + responseTimeMs: responseTimeMs, + httpProtocol: detectedProtocol, + http3Advertised: http3Advertised + ) + + return headers.isEmpty ? .empty("No HTTP headers returned") : .success(result) + } catch { + return .error(error.localizedDescription) } - .sorted { $0.name.lowercased() < $1.name.lowercased() } - - let networkProtocolName = metricsDelegate.metrics?.transactionMetrics - .compactMap { $0.networkProtocolName } - .last - let detectedProtocol = protocolLabel(for: networkProtocolName) - let altSvcValue = headerValue(named: "alt-svc", in: httpResponse) - let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true - - return HTTPHeadersResult( - headers: headers, - statusCode: httpResponse.statusCode, - responseTimeMs: responseTimeMs, - httpProtocol: detectedProtocol, - http3Advertised: http3Advertised - ) } private static func protocolLabel(for networkProtocolName: String?) -> String? { diff --git a/DomainDig/HistoryView.swift b/DomainDig/HistoryView.swift index 52b0070..cc4321f 100644 --- a/DomainDig/HistoryView.swift +++ b/DomainDig/HistoryView.swift @@ -1,15 +1,13 @@ import SwiftUI -import MapKit struct HistoryView: View { @Bindable var viewModel: DomainViewModel - @Environment(\.dismiss) private var dismiss - private let dateFmt: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .short - return f + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter }() var body: some View { @@ -22,15 +20,22 @@ struct HistoryView: View { } else { ForEach(viewModel.history) { entry in NavigationLink { - HistoryDetailView(entry: entry) + HistoryDetailView(viewModel: viewModel, entry: entry) } label: { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 4) { Text(entry.domain) .font(.system(.callout, design: .monospaced)) .foregroundStyle(.primary) - Text(dateFmt.string(from: entry.timestamp)) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) + HStack(spacing: 8) { + Text(dateFormatter.string(from: entry.timestamp)) + Text("Snapshot") + Text(entry.resolverDisplayName) + if let totalLookupDurationMs = entry.totalLookupDurationMs { + Text("\(totalLookupDurationMs) ms") + } + } + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) } } .listRowBackground(Color(.systemGray6).opacity(0.5)) @@ -52,47 +57,102 @@ struct HistoryView: View { } } -// MARK: - History Detail View (Read-Only Cached Results) - struct HistoryDetailView: View { + @Bindable var viewModel: DomainViewModel let entry: HistoryEntry - private let dateFmt: DateFormatter = { - let f = DateFormatter() - f.dateStyle = .medium - f.timeStyle = .short - return f + private let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter }() + private var snapshot: LookupSnapshot { + entry.snapshot + } + var body: some View { ScrollView(.vertical) { VStack(alignment: .leading, spacing: 0) { - cachedBanner - reachabilitySection - redirectChainSection - dnsSection - emailSecuritySection - sslSection - httpHeadersSection - ipGeolocationSection - portScanSection + snapshotBanner + SummaryView(fields: DomainViewModel.summaryFields(from: snapshot)) + .padding(.top, 8) + DomainSectionView(rows: DomainViewModel.domainRows(from: snapshot)) + .padding(.top, 16) + DNSSectionView( + dnssecLabel: DomainViewModel.dnssecLabel(from: snapshot), + sections: DomainViewModel.dnsRows(from: snapshot), + ptrMessage: DomainViewModel.ptrMessage(from: snapshot), + loading: false, + sectionError: snapshot.dnsError + ) + .padding(.top, 16) + WebSectionView( + certificateRows: DomainViewModel.webCertificateRows(from: snapshot), + sslInfo: snapshot.sslInfo, + sslLoading: false, + sslError: snapshot.sslError, + responseRows: DomainViewModel.webResponseRows(from: snapshot), + headers: snapshot.httpHeaders, + headersLoading: false, + headersError: snapshot.httpHeadersError, + redirects: DomainViewModel.redirectRows(from: snapshot), + redirectLoading: false, + redirectError: snapshot.redirectChainError, + finalURL: snapshot.redirectChain.last?.url + ) + .padding(.top, 16) + EmailSectionView( + rows: DomainViewModel.emailRows(from: snapshot), + loading: false, + error: snapshot.emailSecurityError + ) + .padding(.top, 16) + NetworkSectionView( + reachabilityRows: DomainViewModel.reachabilityRows(from: snapshot), + reachabilityLoading: false, + reachabilityError: snapshot.reachabilityError, + locationRows: DomainViewModel.locationRows(from: snapshot), + geolocation: snapshot.ipGeolocation, + geolocationLoading: false, + geolocationError: snapshot.ipGeolocationError, + standardPortRows: DomainViewModel.portRows(from: snapshot, kind: .standard), + customPortRows: DomainViewModel.portRows(from: snapshot, kind: .custom), + portScanLoading: false, + portScanError: snapshot.portScanError, + customPortScanLoading: false, + customPortScanError: nil, + isCloudflareProxied: snapshot.httpHeaders.contains(where: { $0.name.lowercased() == "cf-ray" }), + customPortsExpanded: .constant(false), + customPortInput: .constant(""), + onScanCustomPorts: {} + ) + .padding(.top, 16) } .padding(.horizontal) .padding(.bottom, 32) } .background(Color.black) .navigationTitle(entry.domain) + .toolbar { + Button("Re-run") { + viewModel.rerunLookup(from: entry) + } + } .preferredColorScheme(.dark) } - // MARK: - Cached Banner - - private var cachedBanner: some View { - HStack(spacing: 6) { + private var snapshotBanner: some View { + HStack(spacing: 8) { Image(systemName: "archivebox") .font(.caption) - Text("Cached result from \(dateFmt.string(from: entry.timestamp))") + Text("Snapshot from \(dateFormatter.string(from: entry.timestamp))") .font(.system(.caption, design: .monospaced)) + Spacer() + Text("Live re-run available") + .font(.system(.caption2, design: .monospaced)) + .foregroundStyle(.secondary) } .foregroundStyle(.secondary) .padding(8) @@ -101,440 +161,4 @@ struct HistoryDetailView: View { .cornerRadius(6) .padding(.vertical, 12) } - - // MARK: - Reachability - - private var reachabilitySection: some View { - VStack(alignment: .leading, spacing: 12) { - if !entry.reachabilityResults.isEmpty { - sectionHeader("Reachability") - VStack(alignment: .leading, spacing: 4) { - ForEach(entry.reachabilityResults) { result in - HStack(spacing: 8) { - Circle() - .fill(result.reachable ? Color.green : Color.red) - .frame(width: 8, height: 8) - Text("Port \(result.port)") - .font(.system(.caption, design: .monospaced)) - if result.reachable, let ms = result.latencyMs { - Text("\(ms)ms") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } else if !result.reachable { - Text("—") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - Spacer() - Text(result.reachable ? "Reachable" : "Unreachable") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(result.reachable ? .green : .red) - } - } - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } - } - .padding(.top, 8) - } - - // MARK: - Redirect Chain - - private var redirectChainSection: some View { - VStack(alignment: .leading, spacing: 12) { - if !entry.redirectChain.isEmpty { - sectionHeader("Redirect Chain") - if entry.redirectChain.count == 1, - let only = entry.redirectChain.first, - only.isFinal, !(300...399).contains(only.statusCode) { - Text("No redirects — direct connection") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } else { - horizontallyScrollableCard { - ForEach(entry.redirectChain) { hop in - HStack(alignment: .top, spacing: 6) { - Text("\(hop.stepNumber)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - .frame(width: 16, alignment: .trailing) - Text("\(hop.statusCode)") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.cyan) - .frame(width: 30, alignment: .leading) - Text(hop.url) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - if hop.isFinal { - Text("(final)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - } - .padding(.top, 16) - } - - // MARK: - DNS - - private var dnsSection: some View { - VStack(alignment: .leading, spacing: 12) { - sectionHeader("DNS Records") - ForEach(entry.dnsSections) { section in - horizontallyScrollableCard { - Text(section.recordType.rawValue) - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) - - if let error = section.error { - errorLabel(error) - } else if section.records.isEmpty { - Text("No records found") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } else { - recordRows(section.records) - } - - if !section.wildcardRecords.isEmpty { - Text("*.\(entry.domain)") - .font(.system(.caption, design: .monospaced)) - .fontWeight(.medium) - .foregroundStyle(.cyan.opacity(0.7)) - .padding(.top, 4) - recordRows(section.wildcardRecords) - } - } - - if section.recordType == .A { - horizontallyScrollableCard { - Text("PTR (Reverse DNS)") - .font(.system(.subheadline, design: .monospaced)) - .fontWeight(.semibold) - .foregroundStyle(.cyan) - - if let ptr = entry.ptrRecord { - Text(ptr) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } else { - Text("No PTR record found") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - } - .padding(.top, 16) - } - - // MARK: - Email Security - - @State private var expandedEmailField: String? - - private var emailSecuritySection: some View { - VStack(alignment: .leading, spacing: 12) { - if let email = entry.emailSecurity { - sectionHeader("Email Security") - horizontallyScrollableCard(spacing: 6) { - historyEmailRow("SPF", record: email.spf) - historyEmailRow("DMARC", record: email.dmarc) - historyEmailRow("DKIM", record: email.dkim) - historyEmailRow("MTA-STS", mtaSts: entry.mtaSts ?? email.mtaSts) - historyEmailRow("BIMI", record: email.bimi) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 16) - } - - private func historyEmailRow(_ label: String, record: EmailSecurityRecord) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text(label) - .font(.system(.caption, design: .monospaced)) - .fontWeight(.semibold) - .frame(width: 72, alignment: .leading) - Text(record.found ? "✓" : "✗") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(record.found ? .green : .red) - if let value = record.value { - let isExpanded = expandedEmailField == label - let displayValue = isExpanded ? value : String(value.prefix(80)) - Text(displayValue) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - .lineLimit(isExpanded ? nil : 1) - .onTapGesture { - withAnimation { - expandedEmailField = isExpanded ? nil : label - } - } - if let selector = record.matchedSelector { - Text("(selector: \(selector))") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } else { - Text("No record found") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - - private func historyEmailRow(_ label: String, mtaSts: MTASTSResult?) -> some View { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 8) { - Text(label) - .font(.system(.caption, design: .monospaced)) - .fontWeight(.semibold) - .frame(width: 72, alignment: .leading) - Text(mtaSts?.txtFound == true ? "✓" : "✗") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(mtaSts?.txtFound == true ? .green : .red) - if let policyMode = mtaSts?.policyMode { - Text(policyMode) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } else { - Text(mtaSts?.txtFound == true ? "Policy unavailable" : "No record found") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - } - - // MARK: - SSL - - private var sslSection: some View { - VStack(alignment: .leading, spacing: 12) { - if let info = entry.sslInfo { - sectionHeader("SSL / TLS Certificate") - horizontallyScrollableCard(spacing: 8) { - labelRow("Common Name", info.commonName) - labelRow("Issuer", info.issuer) - - VStack(alignment: .leading, spacing: 2) { - Text("SANs") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - ForEach(info.subjectAltNames, id: \.self) { san in - Text(san) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } - - labelRow("Valid From", DateFormatter.certDate.string(from: info.validFrom)) - labelRow("Valid Until", DateFormatter.certDate.string(from: info.validUntil)) - - HStack { - Text("Days Until Expiry") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Spacer() - Text("\(info.daysUntilExpiry)") - .font(.system(.caption, design: .monospaced)) - .fontWeight(.bold) - .foregroundStyle(expiryColor(info.daysUntilExpiry)) - } - - labelRow("Chain Depth", "\(info.chainDepth)") - } - } - } - .padding(.top, 16) - } - - // MARK: - HTTP Headers - - private var httpHeadersSection: some View { - VStack(alignment: .leading, spacing: 12) { - if !entry.httpHeaders.isEmpty { - sectionHeader("HTTP Headers") - horizontallyScrollableCard { - ForEach(entry.httpHeaders) { header in - HStack(alignment: .top, spacing: 4) { - Text(header.name + ":") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(header.isSecurityHeader ? .yellow : .cyan) - Text(header.value) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } - } - } - } - } - .padding(.top, 16) - } - - // MARK: - IP Geolocation - - private var ipGeolocationSection: some View { - VStack(alignment: .leading, spacing: 12) { - if let geo = entry.ipGeolocation { - sectionHeader("IP Location") - VStack(alignment: .leading, spacing: 6) { - horizontallyScrollableContent(spacing: 6) { - labelRow("IP", geo.ip) - if let org = geo.org { - labelRow("Org / ISP", org) - } - let location = [geo.city, geo.region, geo.country_name].compactMap { $0 }.joined(separator: ", ") - if !location.isEmpty { - labelRow("Location", location) - } - } - - if let lat = geo.latitude, let lon = geo.longitude { - let coordinate = CLLocationCoordinate2D(latitude: lat, longitude: lon) - Map(initialPosition: .region(MKCoordinateRegion( - center: coordinate, - span: MKCoordinateSpan(latitudeDelta: 1, longitudeDelta: 1) - ))) { - Marker(geo.ip, coordinate: coordinate) - } - .mapStyle(.standard) - .frame(maxWidth: .infinity) - .frame(height: 180) - .cornerRadius(8) - } - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } - } - .padding(.top, 16) - } - - // MARK: - Port Scan - - private var portScanSection: some View { - VStack(alignment: .leading, spacing: 12) { - if !entry.portScanResults.isEmpty { - sectionHeader("Open Ports") - VStack(alignment: .leading, spacing: 4) { - ForEach(entry.portScanResults) { result in - HStack(spacing: 8) { - Circle() - .fill(result.open ? Color.green : Color(.systemGray4)) - .frame(width: 8, height: 8) - Text("\(result.port)") - .font(.system(.caption, design: .monospaced)) - .frame(width: 44, alignment: .leading) - Text(result.service) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(result.open ? .primary : .secondary) - Spacer() - if result.open { - Text("Open") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.green) - } - } - } - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } - } - .padding(.top, 16) - } - - // MARK: - Helpers - - private func sectionHeader(_ title: String) -> some View { - Text(title) - .font(.system(.headline, design: .default)) - .foregroundStyle(.white) - } - - private func labelRow(_ label: String, _ value: String) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(.caption, design: .monospaced)) - .textSelection(.enabled) - } - } - - private func recordRows(_ records: [DNSRecord]) -> some View { - ForEach(records) { record in - HStack(alignment: .top) { - Text(record.value) - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - Spacer() - Text("TTL \(record.ttl)") - .font(.system(.caption2, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - } - - private func horizontallyScrollableCard( - spacing: CGFloat = 4, - @ViewBuilder content: () -> Content - ) -> some View { - horizontallyScrollableContent(spacing: spacing) { - content() - } - .padding(10) - .background(Color(.systemGray6).opacity(0.5)) - .cornerRadius(6) - } - - private func horizontallyScrollableContent( - spacing: CGFloat = 4, - @ViewBuilder content: () -> Content - ) -> some View { - ScrollView(.horizontal) { - VStack(alignment: .leading, spacing: spacing) { - content() - } - .scrollTargetLayout() - } - .scrollBounceBehavior(.basedOnSize, axes: .horizontal) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func errorLabel(_ message: String) -> some View { - Label(message, systemImage: "exclamationmark.triangle.fill") - .font(.system(.caption, design: .monospaced)) - .foregroundStyle(.red) - .padding(8) - } - - private func expiryColor(_ days: Int) -> Color { - if days < 30 { return .red } - if days < 60 { return .yellow } - return .green - } } diff --git a/DomainDig/IPGeolocationService.swift b/DomainDig/IPGeolocationService.swift index 6c60295..809dd28 100644 --- a/DomainDig/IPGeolocationService.swift +++ b/DomainDig/IPGeolocationService.swift @@ -1,17 +1,22 @@ import Foundation struct IPGeolocationService { - static func lookup(ip: String) async throws -> IPGeolocation { + static func lookup(ip: String) async -> ServiceResult { let url = URL(string: "https://ipapi.co/\(ip)/json/")! let request = URLRequest(url: url, timeoutInterval: 10) - let (data, response) = try await URLSession.shared.data(for: request) + do { + let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw URLError(.badServerResponse) - } + guard let httpResponse = response as? HTTPURLResponse, + httpResponse.statusCode == 200 else { + return .error(URLError(.badServerResponse).localizedDescription) + } - return try JSONDecoder().decode(IPGeolocation.self, from: data) + let geolocation = try JSONDecoder().decode(IPGeolocation.self, from: data) + return .success(geolocation) + } catch { + return .error(error.localizedDescription) + } } } diff --git a/DomainDig/Models.swift b/DomainDig/Models.swift index 8a84adf..d854137 100644 --- a/DomainDig/Models.swift +++ b/DomainDig/Models.swift @@ -1,5 +1,11 @@ import Foundation +enum ServiceResult { + case success(Value) + case empty(String) + case error(String) +} + // MARK: - DNS Models enum DNSRecordType: String, CaseIterable, Codable { @@ -13,6 +19,7 @@ enum DNSRecordType: String, CaseIterable, Codable { case SRV case CAA case DS + case PTR var queryType: Int { switch self { @@ -26,6 +33,7 @@ enum DNSRecordType: String, CaseIterable, Codable { case .SRV: return 33 case .CAA: return 257 case .DS: return 43 + case .PTR: return 12 } } @@ -223,18 +231,34 @@ struct RedirectHop: Identifiable, Codable { // MARK: - Port Scan Models +enum PortScanKind: String, Codable { + case standard + case custom +} + struct PortScanResult: Identifiable, Codable { var id = UUID() let port: UInt16 let service: String let open: Bool var banner: String? - - nonisolated init(port: UInt16, service: String, open: Bool, banner: String? = nil) { + let kind: PortScanKind + let durationMs: Int? + + nonisolated init( + port: UInt16, + service: String, + open: Bool, + banner: String? = nil, + kind: PortScanKind = .standard, + durationMs: Int? = nil + ) { self.port = port self.service = service self.open = open self.banner = banner + self.kind = kind + self.durationMs = durationMs } init(from decoder: Decoder) throws { @@ -244,6 +268,8 @@ struct PortScanResult: Identifiable, Codable { service = try container.decode(String.self, forKey: .service) open = try container.decode(Bool.self, forKey: .open) banner = try container.decodeIfPresent(String.self, forKey: .banner) + kind = try container.decodeIfPresent(PortScanKind.self, forKey: .kind) ?? .standard + durationMs = try container.decodeIfPresent(Int.self, forKey: .durationMs) } } @@ -264,13 +290,28 @@ struct HistoryEntry: Identifiable, Codable { var redirectChain: [RedirectHop] var portScanResults: [PortScanResult] var hstsPreloaded: Bool? + var resolverDisplayName: String + var resolverURLString: String + var totalLookupDurationMs: Int? + var sslError: String? + var httpHeadersError: String? + var reachabilityError: String? + var ipGeolocationError: String? + var emailSecurityError: String? + var ptrError: String? + var redirectChainError: String? + var portScanError: String? init(domain: String, timestamp: Date, dnsSections: [DNSSection], sslInfo: SSLCertificateInfo?, httpHeaders: [HTTPHeader], reachabilityResults: [PortReachability], ipGeolocation: IPGeolocation?, emailSecurity: EmailSecurityResult? = nil, mtaSts: MTASTSResult? = nil, ptrRecord: String? = nil, redirectChain: [RedirectHop] = [], portScanResults: [PortScanResult] = [], - hstsPreloaded: Bool? = nil) { + hstsPreloaded: Bool? = nil, resolverDisplayName: String, resolverURLString: String, + totalLookupDurationMs: Int? = nil, sslError: String? = nil, httpHeadersError: String? = nil, + reachabilityError: String? = nil, ipGeolocationError: String? = nil, + emailSecurityError: String? = nil, ptrError: String? = nil, + redirectChainError: String? = nil, portScanError: String? = nil) { self.domain = domain self.timestamp = timestamp self.dnsSections = dnsSections @@ -284,6 +325,17 @@ struct HistoryEntry: Identifiable, Codable { self.redirectChain = redirectChain self.portScanResults = portScanResults self.hstsPreloaded = hstsPreloaded + self.resolverDisplayName = resolverDisplayName + self.resolverURLString = resolverURLString + self.totalLookupDurationMs = totalLookupDurationMs + self.sslError = sslError + self.httpHeadersError = httpHeadersError + self.reachabilityError = reachabilityError + self.ipGeolocationError = ipGeolocationError + self.emailSecurityError = emailSecurityError + self.ptrError = ptrError + self.redirectChainError = redirectChainError + self.portScanError = portScanError } init(from decoder: Decoder) throws { @@ -302,6 +354,17 @@ struct HistoryEntry: Identifiable, Codable { redirectChain = try container.decodeIfPresent([RedirectHop].self, forKey: .redirectChain) ?? [] portScanResults = try container.decodeIfPresent([PortScanResult].self, forKey: .portScanResults) ?? [] hstsPreloaded = try container.decodeIfPresent(Bool.self, forKey: .hstsPreloaded) + resolverDisplayName = try container.decodeIfPresent(String.self, forKey: .resolverDisplayName) ?? "Cloudflare" + resolverURLString = try container.decodeIfPresent(String.self, forKey: .resolverURLString) ?? DNSResolverOption.defaultURLString + totalLookupDurationMs = try container.decodeIfPresent(Int.self, forKey: .totalLookupDurationMs) + sslError = try container.decodeIfPresent(String.self, forKey: .sslError) + httpHeadersError = try container.decodeIfPresent(String.self, forKey: .httpHeadersError) + reachabilityError = try container.decodeIfPresent(String.self, forKey: .reachabilityError) + ipGeolocationError = try container.decodeIfPresent(String.self, forKey: .ipGeolocationError) + emailSecurityError = try container.decodeIfPresent(String.self, forKey: .emailSecurityError) + ptrError = try container.decodeIfPresent(String.self, forKey: .ptrError) + redirectChainError = try container.decodeIfPresent(String.self, forKey: .redirectChainError) + portScanError = try container.decodeIfPresent(String.self, forKey: .portScanError) } } diff --git a/DomainDig/PortScanService.swift b/DomainDig/PortScanService.swift index 4dc9001..36c2827 100644 --- a/DomainDig/PortScanService.swift +++ b/DomainDig/PortScanService.swift @@ -20,12 +20,18 @@ struct PortScanService { PortInfo(port: 8443, service: "HTTPS Alt"), ] - static func scanAll(domain: String) async -> [PortScanResult] { + static func scanAll(domain: String) async -> ServiceResult<[PortScanResult]> { await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in for info in ports { group.addTask { - let open = await probe(domain: domain, port: info.port) - return PortScanResult(port: info.port, service: info.service, open: open) + let result = await probe(domain: domain, port: info.port) + return PortScanResult( + port: info.port, + service: info.service, + open: result.open, + kind: .standard, + durationMs: result.durationMs + ) } } @@ -35,20 +41,24 @@ struct PortScanService { } // Sort by port number - return results.sorted { $0.port < $1.port } + let sorted = results.sorted { $0.port < $1.port } + return sorted.isEmpty ? [] : sorted } + .pipe { $0.isEmpty ? .empty("No port scan results") : .success($0) } } - static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> [PortScanResult] { + static func scanPorts(domain: String, ports: [UInt16], timeout: TimeInterval) async -> ServiceResult<[PortScanResult]> { await withTaskGroup(of: PortScanResult.self, returning: [PortScanResult].self) { group in for port in ports { let service = self.ports.first(where: { $0.port == port })?.service ?? "Custom" group.addTask { - let open = await probe(domain: domain, port: port, timeout: timeout) + let result = await probe(domain: domain, port: port, timeout: timeout) return PortScanResult( port: port, service: service, - open: open + open: result.open, + kind: .custom, + durationMs: result.durationMs ) } } @@ -58,8 +68,10 @@ struct PortScanService { results.append(result) } - return results.sorted { $0.port < $1.port } + let sorted = results.sorted { $0.port < $1.port } + return sorted.isEmpty ? [] : sorted } + .pipe { $0.isEmpty ? .empty("No custom port scan results") : .success($0) } } static func grabBanner(host: String, port: UInt16, timeout: TimeInterval = 3.0) async -> String? { @@ -111,11 +123,11 @@ struct PortScanService { } } - private static func probe(domain: String, port: UInt16) async -> Bool { + private static func probe(domain: String, port: UInt16) async -> PortProbeResult { await probe(domain: domain, port: port, timeout: 5) } - private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> Bool { + private static func probe(domain: String, port: UInt16, timeout: TimeInterval) async -> PortProbeResult { await withCheckedContinuation { continuation in let host = NWEndpoint.Host(domain) let nwPort = NWEndpoint.Port(rawValue: port)! @@ -143,13 +155,19 @@ struct PortScanService { } } +private struct PortProbeResult: Sendable { + let open: Bool + let durationMs: Int? +} + private final class ProbeContext: @unchecked Sendable { private let connection: NWConnection - private let continuation: CheckedContinuation + private let continuation: CheckedContinuation + private let start = CFAbsoluteTimeGetCurrent() private let lock = NSLock() private nonisolated(unsafe) var resumed = false - init(connection: NWConnection, continuation: CheckedContinuation) { + init(connection: NWConnection, continuation: CheckedContinuation) { self.connection = connection self.continuation = continuation } @@ -164,7 +182,17 @@ private final class ProbeContext: @unchecked Sendable { lock.unlock() connection.cancel() - continuation.resume(returning: open) + let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - start) * 1000) + continuation.resume(returning: PortProbeResult( + open: open, + durationMs: elapsedMs >= 0 ? elapsedMs : nil + )) + } +} + +private extension Array { + func pipe(_ transform: (Self) -> T) -> T { + transform(self) } } diff --git a/DomainDig/ReachabilityService.swift b/DomainDig/ReachabilityService.swift index 0bb30ae..3fb7dd9 100644 --- a/DomainDig/ReachabilityService.swift +++ b/DomainDig/ReachabilityService.swift @@ -29,10 +29,11 @@ struct ReachabilityService { } } - static func checkAll(domain: String) async -> [PortReachability] { + static func checkAll(domain: String) async -> ServiceResult<[PortReachability]> { async let port443 = check(domain: domain, port: 443) async let port80 = check(domain: domain, port: 80) - return await [port443, port80] + let results = await [port443, port80] + return results.isEmpty ? .empty("No reachability results") : .success(results) } } diff --git a/DomainDig/RedirectChainService.swift b/DomainDig/RedirectChainService.swift index 541e118..d55a4aa 100644 --- a/DomainDig/RedirectChainService.swift +++ b/DomainDig/RedirectChainService.swift @@ -1,12 +1,17 @@ import Foundation struct RedirectChainService { - static func trace(domain: String) async throws -> [RedirectHop] { - // Try HTTPS first (avoids ATS issues), fall back to HTTP if it fails entirely + static func trace(domain: String) async -> ServiceResult<[RedirectHop]> { do { - return try await followChain(startingURL: URL(string: "https://\(domain)")!) + let hops = try await followChain(startingURL: URL(string: "https://\(domain)")!) + return hops.isEmpty ? .empty("No redirect data available") : .success(hops) } catch { - return try await followChain(startingURL: URL(string: "http://\(domain)")!) + do { + let hops = try await followChain(startingURL: URL(string: "http://\(domain)")!) + return hops.isEmpty ? .empty("No redirect data available") : .success(hops) + } catch { + return .error(error.localizedDescription) + } } } diff --git a/DomainDig/ReverseDNSService.swift b/DomainDig/ReverseDNSService.swift index caab0f7..3b212a8 100644 --- a/DomainDig/ReverseDNSService.swift +++ b/DomainDig/ReverseDNSService.swift @@ -1,48 +1,27 @@ import Foundation struct ReverseDNSService { - /// Look up the PTR record for an IPv4 address via Cloudflare DoH. - static func lookup(ip: String) async -> String? { + static func lookup(ip: String, resolverURLString: String) async -> ServiceResult { let octets = ip.split(separator: ".") - guard octets.count == 4 else { return nil } + guard octets.count == 4 else { + return .error("Invalid IPv4 address") + } let reversed = octets.reversed().joined(separator: ".") let ptrDomain = "\(reversed).in-addr.arpa" - // PTR record type = 12 do { - let records = try await lookupPTR(domain: ptrDomain) - return records.first + let records = try await DNSLookupService.lookup( + domain: ptrDomain, + recordType: .PTR, + resolverURLString: resolverURLString + ) + if let record = records.first?.value { + return .success(record) + } + return .empty("No PTR record found") } catch { - return nil + return .error(error.localizedDescription) } } - - private static func lookupPTR(domain: String) async throws -> [String] { - var components = URLComponents(string: "https://cloudflare-dns.com/dns-query")! - components.queryItems = [ - URLQueryItem(name: "name", value: domain), - URLQueryItem(name: "type", value: "12") // PTR - ] - - var request = URLRequest(url: components.url!) - request.setValue("application/dns-json", forHTTPHeaderField: "Accept") - - let (data, response) = try await URLSession.shared.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse, - httpResponse.statusCode == 200 else { - throw URLError(.badServerResponse) - } - - let dnsResponse = try JSONDecoder().decode(CloudflareDNSResponse.self, from: data) - - guard let answers = dnsResponse.Answer else { - return [] - } - - return answers - .filter { $0.type == 12 } - .map { $0.data.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) } - } } diff --git a/DomainDig/SSLCheckService.swift b/DomainDig/SSLCheckService.swift index 6cf8ec3..e180d4e 100644 --- a/DomainDig/SSLCheckService.swift +++ b/DomainDig/SSLCheckService.swift @@ -3,7 +3,7 @@ import Security struct SSLCheckService { - static func check(domain: String) async throws -> SSLCertificateInfo { + static func check(domain: String) async -> ServiceResult { let delegate = SSLSessionDelegate() let session = URLSession( configuration: .ephemeral, @@ -15,14 +15,17 @@ struct SSLCheckService { let url = URL(string: "https://\(domain)")! let request = URLRequest(url: url, timeoutInterval: 10) - // We only need to establish the connection to grab the cert - _ = try await session.data(for: request) + do { + _ = try await session.data(for: request) - guard let trust = delegate.serverTrust else { - throw SSLError.noCertificate - } + guard let trust = delegate.serverTrust else { + return .empty(SSLError.noCertificate.localizedDescription) + } - return try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata) + return .success(try extractCertificateInfo(from: trust, metadata: delegate.tlsMetadata)) + } catch { + return .error(error.localizedDescription) + } } static func checkHSTSPreload(domain: String) async -> Bool? { -- cgit v1.2.3