summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/ContentView.swift1184
-rw-r--r--DomainDig/DNSLookupService.swift23
-rw-r--r--DomainDig/DomainViewModel.swift1441
-rw-r--r--DomainDig/EmailSecurityService.swift7
-rw-r--r--DomainDig/HTTPHeadersService.swift61
-rw-r--r--DomainDig/HistoryView.swift562
-rw-r--r--DomainDig/IPGeolocationService.swift19
-rw-r--r--DomainDig/Models.swift69
-rw-r--r--DomainDig/PortScanService.swift54
-rw-r--r--DomainDig/ReachabilityService.swift5
-rw-r--r--DomainDig/RedirectChainService.swift13
-rw-r--r--DomainDig/ReverseDNSService.swift49
-rw-r--r--DomainDig/SSLCheckService.swift17
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<UInt16>()
+ 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<Content: View>(
- 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<Content: View>(
- 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<UInt16>()
- 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<Content: View>: 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<Void, Never>?
+ private var customPortScanTask: Task<Void, Never>?
+ 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<EmailSecurityResult> {
// 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<HTTPHeadersResult> {
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<Content: View>(
- 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<Content: View>(
- 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<IPGeolocation> {
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<Value> {
+ 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<Bool, Never>
+ private let continuation: CheckedContinuation<PortProbeResult, Never>
+ private let start = CFAbsoluteTimeGetCurrent()
private let lock = NSLock()
private nonisolated(unsafe) var resumed = false
- init(connection: NWConnection, continuation: CheckedContinuation<Bool, Never>) {
+ init(connection: NWConnection, continuation: CheckedContinuation<PortProbeResult, Never>) {
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<T>(_ 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<String> {
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<SSLCertificateInfo> {
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? {