Commit 2c38b5edee

2c38b5edee339e5fe9060a835d9772a71606dc85

parent: 8465ef359f

Verified · cmc

cmc <hello@cleberg.net> · 2026-04-03 22:14 UTC

Add HTTP header grading and protocol metadata

Layout: unified · split

DomainDig/ContentView.swift +69 −1
@@ -544,7 +544,19 @@ struct ContentView: View {
544544
545545 private var httpHeadersSection: some View {
546546 VStack(alignment: .leading, spacing: 12) {
547 sectionHeader("HTTP Headers")
547 HStack(spacing: 8) {
548 sectionHeader("HTTP Headers")
549 if let grade = viewModel.httpSecurityGrade {
550 Text(grade)
551 .font(.system(.caption, design: .monospaced))
552 .foregroundStyle(httpSecurityGradeColor(for: grade))
553 .padding(.horizontal, 8)
554 .padding(.vertical, 2)
555 .background(httpSecurityGradeColor(for: grade).opacity(0.18))
556 .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
557 }
558 Spacer()
559 }
548560
549561 if viewModel.httpHeadersLoading {
550562 ProgressView("Fetching headers…")
@@ -554,6 +566,26 @@ struct ContentView: View {
554566 errorLabel(error)
555567 } else {
556568 horizontallyScrollableCard {
569 if !httpStatusSummaryParts.isEmpty || http3AvailabilityNote != nil {
570 HStack(alignment: .top, spacing: 0) {
571 ForEach(Array(httpStatusSummaryParts.enumerated()), id: \.offset) { index, part in
572 if index > 0 {
573 Text(" ")
574 .font(.system(.caption, design: .monospaced))
575 }
576 Text(part.text)
577 .font(.system(.caption, design: .monospaced))
578 .foregroundStyle(part.color)
579 }
580 if let http3AvailabilityNote {
581 Text(" ")
582 .font(.system(.caption, design: .monospaced))
583 Text(http3AvailabilityNote)
584 .font(.system(.caption, design: .monospaced))
585 .foregroundStyle(.secondary)
586 }
587 }
588 }
557589 ForEach(viewModel.httpHeaders) { header in
558590 HStack(alignment: .top, spacing: 4) {
559591 Text(header.name + ":")
@@ -750,6 +782,42 @@ struct ContentView: View {
750782 .padding(8)
751783 }
752784
785 private var httpStatusSummaryParts: [(text: String, color: Color)] {
786 var parts: [(text: String, color: Color)] = []
787
788 if let statusCode = viewModel.httpStatusCode {
789 parts.append(("HTTP \(statusCode)", .cyan))
790 }
791 if let responseTimeMs = viewModel.httpResponseTimeMs {
792 parts.append(("\(responseTimeMs)ms", .secondary))
793 }
794 if let httpProtocol = viewModel.httpProtocol {
795 parts.append((httpProtocol, .secondary))
796 }
797
798 return parts
799 }
800
801 private var http3AvailabilityNote: String? {
802 guard viewModel.http3Advertised, viewModel.httpProtocol != "HTTP/3" else {
803 return nil
804 }
805 return "(HTTP/3 available)"
806 }
807
808 private func httpSecurityGradeColor(for grade: String) -> Color {
809 switch grade {
810 case "A", "B":
811 .green
812 case "C":
813 .yellow
814 case "D", "F":
815 .red
816 default:
817 .secondary
818 }
819 }
820
753821 private func expiryColor(_ days: Int) -> Color {
754822 if days < 30 { return .red }
755823 if days < 60 { return .yellow }
DomainDig/DomainViewModel.swift +47 −2
@@ -20,6 +20,11 @@ final class DomainViewModel {
2020
2121 // HTTP Headers
2222 var httpHeaders: [HTTPHeader] = []
23 var httpSecurityGrade: String?
24 var httpStatusCode: Int?
25 var httpResponseTimeMs: Int?
26 var httpProtocol: String?
27 var http3Advertised = false
2328 var httpHeadersLoading = false
2429 var httpHeadersError: String?
2530
@@ -168,6 +173,11 @@ final class DomainViewModel {
168173 hstsPreloaded = nil
169174 hstsLoading = false
170175 httpHeaders = []
176 httpSecurityGrade = nil
177 httpStatusCode = nil
178 httpResponseTimeMs = nil
179 httpProtocol = nil
180 http3Advertised = false
171181 httpHeadersError = nil
172182 httpHeadersLoading = false
173183 reachabilityResults = []
@@ -210,6 +220,11 @@ final class DomainViewModel {
210220 hstsPreloaded = nil
211221 hstsLoading = true
212222 httpHeaders = []
223 httpSecurityGrade = nil
224 httpStatusCode = nil
225 httpResponseTimeMs = nil
226 httpProtocol = nil
227 http3Advertised = false
213228 httpHeadersError = nil
214229 httpHeadersLoading = true
215230 reachabilityResults = []
@@ -300,8 +315,13 @@ final class DomainViewModel {
300315
301316 private func runHTTPHeaders(domain: String) async {
302317 do {
303 let headers = try await HTTPHeadersService.fetch(domain: domain)
304 httpHeaders = headers
318 let result = try await HTTPHeadersService.fetch(domain: domain)
319 httpHeaders = result.headers
320 httpSecurityGrade = HTTPSecurityGrade.grade(for: result.headers).rawValue
321 httpStatusCode = result.statusCode
322 httpResponseTimeMs = result.responseTimeMs
323 httpProtocol = result.httpProtocol
324 http3Advertised = result.http3Advertised
305325 } catch {
306326 httpHeadersError = error.localizedDescription
307327 }
@@ -381,6 +401,11 @@ final class DomainViewModel {
381401 sslError: sslError,
382402 hstsPreloaded: hstsPreloaded,
383403 httpHeaders: httpHeaders,
404 httpSecurityGrade: httpSecurityGrade,
405 httpStatusCode: httpStatusCode,
406 httpResponseTimeMs: httpResponseTimeMs,
407 httpProtocol: httpProtocol,
408 http3Advertised: http3Advertised,
384409 httpHeadersError: httpHeadersError,
385410 reachabilityResults: reachabilityResults,
386411 ipGeolocation: ipGeolocation,
@@ -400,6 +425,11 @@ final class DomainViewModel {
400425 sslError: String? = nil,
401426 hstsPreloaded: Bool? = nil,
402427 httpHeaders: [HTTPHeader],
428 httpSecurityGrade: String? = nil,
429 httpStatusCode: Int? = nil,
430 httpResponseTimeMs: Int? = nil,
431 httpProtocol: String? = nil,
432 http3Advertised: Bool = false,
403433 httpHeadersError: String? = nil,
404434 reachabilityResults: [PortReachability],
405435 ipGeolocation: IPGeolocation?,
@@ -548,6 +578,21 @@ final class DomainViewModel {
548578 for header in httpHeaders {
549579 lines.append(" \(header.name): \(header.value)")
550580 }
581 if let httpSecurityGrade {
582 lines.append("Grade: \(httpSecurityGrade)")
583 }
584 if let httpStatusCode {
585 lines.append("Status: \(httpStatusCode)")
586 }
587 if let httpResponseTimeMs {
588 lines.append("Response Time: \(httpResponseTimeMs)ms")
589 }
590 if let httpProtocol {
591 lines.append("Protocol: \(httpProtocol)")
592 }
593 if http3Advertised {
594 lines.append("HTTP/3 Advertised: Yes")
595 }
551596 } else if let error = httpHeadersError {
552597 lines.append("")
553598 lines.append("HTTP Headers")
DomainDig/HTTPHeadersService.swift +94 −6
@@ -1,22 +1,110 @@
11import Foundation
22
3enum HTTPSecurityGrade: String {
4 case a = "A"
5 case b = "B"
6 case c = "C"
7 case d = "D"
8 case f = "F"
9
10 static func grade(for headers: [HTTPHeader]) -> HTTPSecurityGrade {
11 let presentHeaderNames = Set(headers.map { $0.name.lowercased() })
12 let presentCount = HTTPHeader.securityHeaders.intersection(presentHeaderNames).count
13
14 switch presentCount {
15 case 5:
16 return .a
17 case 4:
18 return .b
19 case 3:
20 return .c
21 case 2:
22 return .d
23 default:
24 return .f
25 }
26 }
27}
28
29struct HTTPHeadersResult {
30 let headers: [HTTPHeader]
31 let statusCode: Int?
32 let responseTimeMs: Int?
33 let httpProtocol: String?
34 let http3Advertised: Bool
35}
36
337struct HTTPHeadersService {
4 static func fetch(domain: String) async throws -> [HTTPHeader] {
38 static func fetch(domain: String) async throws -> HTTPHeadersResult {
539 let url = URL(string: "https://\(domain)")!
640 var request = URLRequest(url: url, timeoutInterval: 10)
741 request.httpMethod = "HEAD"
42 let metricsDelegate = TaskMetricsDelegate()
43 let startTime = Date()
844
9 let (_, response) = try await URLSession.shared.data(for: request)
45 let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate)
46 let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000))
1047
1148 guard let httpResponse = response as? HTTPURLResponse else {
1249 throw URLError(.badServerResponse)
1350 }
1451
15 return httpResponse.allHeaderFields.compactMap { key, value in
16 guard let name = key as? String,
17 let val = value as? String else { return nil }
18 return HTTPHeader(name: name, value: val)
52 let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in
53 guard let name = entry.key as? String,
54 let value = entry.value as? String else { return nil }
55 return HTTPHeader(name: name, value: value)
1956 }
2057 .sorted { $0.name.lowercased() < $1.name.lowercased() }
58
59 let networkProtocolName = metricsDelegate.metrics?.transactionMetrics
60 .compactMap { $0.networkProtocolName }
61 .last
62 let detectedProtocol = protocolLabel(for: networkProtocolName)
63 let altSvcValue = headerValue(named: "alt-svc", in: httpResponse)
64 let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true
65
66 return HTTPHeadersResult(
67 headers: headers,
68 statusCode: httpResponse.statusCode,
69 responseTimeMs: responseTimeMs,
70 httpProtocol: detectedProtocol,
71 http3Advertised: http3Advertised
72 )
73 }
74
75 private static func protocolLabel(for networkProtocolName: String?) -> String? {
76 guard let networkProtocolName else { return nil }
77
78 let normalized = networkProtocolName.lowercased()
79 if normalized == "h2" {
80 return "HTTP/2"
81 }
82 if normalized == "h3" || normalized.hasPrefix("quic") {
83 return "HTTP/3"
84 }
85 if normalized.hasPrefix("http/") {
86 return normalized.uppercased()
87 }
88
89 return networkProtocolName.uppercased()
90 }
91
92 private static func headerValue(named name: String, in response: HTTPURLResponse) -> String? {
93 response.allHeaderFields.first { key, _ in
94 guard let headerName = key as? String else { return false }
95 return headerName.caseInsensitiveCompare(name) == .orderedSame
96 }?.value as? String
97 }
98}
99
100private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate {
101 private(set) var metrics: URLSessionTaskMetrics?
102
103 func urlSession(
104 _ session: URLSession,
105 task: URLSessionTask,
106 didFinishCollecting metrics: URLSessionTaskMetrics
107 ) {
108 self.metrics = metrics
21109 }
22110}