krz/domain-dig

an ios app for DNS & SSL analysis

clone: git clone https://gitbay.org/krz/domain-dig.git

2c38b5edee339e5fe9060a835d9772a71606dc85

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-04-03T22:14:00Z

Add HTTP header grading and protocol metadata
 DomainDig/ContentView.swift        |  70 +++++++++++++++++++++++++-
 DomainDig/DomainViewModel.swift    |  49 +++++++++++++++++-
 DomainDig/HTTPHeadersService.swift | 100 ++++++++++++++++++++++++++++++++++---
 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
     }
 }