summaryrefslogtreecommitdiff
path: root/DomainDig
diff options
context:
space:
mode:
Diffstat (limited to 'DomainDig')
-rw-r--r--DomainDig/ContentView.swift70
-rw-r--r--DomainDig/DomainViewModel.swift49
-rw-r--r--DomainDig/HTTPHeadersService.swift100
3 files changed, 210 insertions, 9 deletions
diff --git a/DomainDig/ContentView.swift b/DomainDig/ContentView.swift
index bb93789..482d3bc 100644
--- a/DomainDig/ContentView.swift
+++ b/DomainDig/ContentView.swift
@@ -544,7 +544,19 @@ struct ContentView: View {
private var httpHeadersSection: some View {
VStack(alignment: .leading, spacing: 12) {
- sectionHeader("HTTP Headers")
+ 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))
+ }
+ Spacer()
+ }
if viewModel.httpHeadersLoading {
ProgressView("Fetching headers…")
@@ -554,6 +566,26 @@ struct ContentView: View {
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(" ")
+ .font(.system(.caption, design: .monospaced))
+ Text(http3AvailabilityNote)
+ .font(.system(.caption, design: .monospaced))
+ .foregroundStyle(.secondary)
+ }
+ }
+ }
ForEach(viewModel.httpHeaders) { header in
HStack(alignment: .top, spacing: 4) {
Text(header.name + ":")
@@ -750,6 +782,42 @@ struct ContentView: View {
.padding(8)
}
+ private var httpStatusSummaryParts: [(text: String, color: Color)] {
+ var parts: [(text: String, color: Color)] = []
+
+ 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))
+ }
+
+ return parts
+ }
+
+ private var http3AvailabilityNote: String? {
+ guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else {
+ return nil
+ }
+ return "(HTTP/3 available)"
+ }
+
+ private func httpSecurityGradeColor(for grade: String) -> Color {
+ switch grade {
+ case "A", "B":
+ .green
+ case "C":
+ .yellow
+ case "D", "F":
+ .red
+ default:
+ .secondary
+ }
+ }
+
private func expiryColor(_ days: Int) -> Color {
if days < 30 { return .red }
if days < 60 { return .yellow }
diff --git a/DomainDig/DomainViewModel.swift b/DomainDig/DomainViewModel.swift
index 16eaabd..0edd371 100644
--- a/DomainDig/DomainViewModel.swift
+++ b/DomainDig/DomainViewModel.swift
@@ -20,6 +20,11 @@ final class DomainViewModel {
// HTTP Headers
var httpHeaders: [HTTPHeader] = []
+ var httpSecurityGrade: String?
+ var httpStatusCode: Int?
+ var httpResponseTimeMs: Int?
+ var httpProtocol: String?
+ var http3Advertised = false
var httpHeadersLoading = false
var httpHeadersError: String?
@@ -168,6 +173,11 @@ final class DomainViewModel {
hstsPreloaded = nil
hstsLoading = false
httpHeaders = []
+ httpSecurityGrade = nil
+ httpStatusCode = nil
+ httpResponseTimeMs = nil
+ httpProtocol = nil
+ http3Advertised = false
httpHeadersError = nil
httpHeadersLoading = false
reachabilityResults = []
@@ -210,6 +220,11 @@ final class DomainViewModel {
hstsPreloaded = nil
hstsLoading = true
httpHeaders = []
+ httpSecurityGrade = nil
+ httpStatusCode = nil
+ httpResponseTimeMs = nil
+ httpProtocol = nil
+ http3Advertised = false
httpHeadersError = nil
httpHeadersLoading = true
reachabilityResults = []
@@ -300,8 +315,13 @@ final class DomainViewModel {
private func runHTTPHeaders(domain: String) async {
do {
- let headers = try await HTTPHeadersService.fetch(domain: domain)
- httpHeaders = headers
+ 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
}
@@ -381,6 +401,11 @@ final class DomainViewModel {
sslError: sslError,
hstsPreloaded: hstsPreloaded,
httpHeaders: httpHeaders,
+ httpSecurityGrade: httpSecurityGrade,
+ httpStatusCode: httpStatusCode,
+ httpResponseTimeMs: httpResponseTimeMs,
+ httpProtocol: httpProtocol,
+ http3Advertised: http3Advertised,
httpHeadersError: httpHeadersError,
reachabilityResults: reachabilityResults,
ipGeolocation: ipGeolocation,
@@ -400,6 +425,11 @@ final class DomainViewModel {
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?,
@@ -548,6 +578,21 @@ final class DomainViewModel {
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)")
+ }
+ if http3Advertised {
+ lines.append("HTTP/3 Advertised: Yes")
+ }
} else if let error = httpHeadersError {
lines.append("")
lines.append("HTTP Headers")
diff --git a/DomainDig/HTTPHeadersService.swift b/DomainDig/HTTPHeadersService.swift
index a297165..04efce6 100644
--- a/DomainDig/HTTPHeadersService.swift
+++ b/DomainDig/HTTPHeadersService.swift
@@ -1,22 +1,110 @@
import Foundation
+enum HTTPSecurityGrade: String {
+ case a = "A"
+ case b = "B"
+ case c = "C"
+ case d = "D"
+ case f = "F"
+
+ static func grade(for headers: [HTTPHeader]) -> HTTPSecurityGrade {
+ let presentHeaderNames = Set(headers.map { $0.name.lowercased() })
+ let presentCount = HTTPHeader.securityHeaders.intersection(presentHeaderNames).count
+
+ switch presentCount {
+ case 5:
+ return .a
+ case 4:
+ return .b
+ case 3:
+ return .c
+ case 2:
+ return .d
+ default:
+ return .f
+ }
+ }
+}
+
+struct HTTPHeadersResult {
+ let headers: [HTTPHeader]
+ let statusCode: Int?
+ let responseTimeMs: Int?
+ let httpProtocol: String?
+ let http3Advertised: Bool
+}
+
struct HTTPHeadersService {
- static func fetch(domain: String) async throws -> [HTTPHeader] {
+ static func fetch(domain: String) async throws -> 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)
+ 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)
}
- return httpResponse.allHeaderFields.compactMap { key, value in
- guard let name = key as? String,
- let val = value as? String else { return nil }
- return HTTPHeader(name: name, value: val)
+ 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
+
+ return HTTPHeadersResult(
+ headers: headers,
+ statusCode: httpResponse.statusCode,
+ responseTimeMs: responseTimeMs,
+ httpProtocol: detectedProtocol,
+ http3Advertised: http3Advertised
+ )
+ }
+
+ private static func protocolLabel(for networkProtocolName: String?) -> String? {
+ guard let networkProtocolName else { return nil }
+
+ let normalized = networkProtocolName.lowercased()
+ if normalized == "h2" {
+ return "HTTP/2"
+ }
+ if normalized == "h3" || normalized.hasPrefix("quic") {
+ return "HTTP/3"
+ }
+ if normalized.hasPrefix("http/") {
+ return normalized.uppercased()
+ }
+
+ return networkProtocolName.uppercased()
+ }
+
+ private static func headerValue(named name: String, in response: HTTPURLResponse) -> String? {
+ response.allHeaderFields.first { key, _ in
+ guard let headerName = key as? String else { return false }
+ return headerName.caseInsensitiveCompare(name) == .orderedSame
+ }?.value as? String
+ }
+}
+
+private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate {
+ private(set) var metrics: URLSessionTaskMetrics?
+
+ func urlSession(
+ _ session: URLSession,
+ task: URLSessionTask,
+ didFinishCollecting metrics: URLSessionTaskMetrics
+ ) {
+ self.metrics = metrics
}
}