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 {
544 544
545 private var httpHeadersSection: some View { 545 private var httpHeadersSection: some View {
546 VStack(alignment: .leading, spacing: 12) { 546 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 }
548 560
549 if viewModel.httpHeadersLoading { 561 if viewModel.httpHeadersLoading {
550 ProgressView("Fetching headers…") 562 ProgressView("Fetching headers…")
@@ -554,6 +566,26 @@ struct ContentView: View {
554 errorLabel(error) 566 errorLabel(error)
555 } else { 567 } else {
556 horizontallyScrollableCard { 568 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 }
557 ForEach(viewModel.httpHeaders) { header in 589 ForEach(viewModel.httpHeaders) { header in
558 HStack(alignment: .top, spacing: 4) { 590 HStack(alignment: .top, spacing: 4) {
559 Text(header.name + ":") 591 Text(header.name + ":")
@@ -750,6 +782,42 @@ struct ContentView: View {
750 .padding(8) 782 .padding(8)
751 } 783 }
752 784
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
753 private func expiryColor(_ days: Int) -> Color { 821 private func expiryColor(_ days: Int) -> Color {
754 if days < 30 { return .red } 822 if days < 30 { return .red }
755 if days < 60 { return .yellow } 823 if days < 60 { return .yellow }
DomainDig/DomainViewModel.swift +47 −2
@@ -20,6 +20,11 @@ final class DomainViewModel {
20 20
21 // HTTP Headers 21 // HTTP Headers
22 var httpHeaders: [HTTPHeader] = [] 22 var httpHeaders: [HTTPHeader] = []
23 var httpSecurityGrade: String?
24 var httpStatusCode: Int?
25 var httpResponseTimeMs: Int?
26 var httpProtocol: String?
27 var http3Advertised = false
23 var httpHeadersLoading = false 28 var httpHeadersLoading = false
24 var httpHeadersError: String? 29 var httpHeadersError: String?
25 30
@@ -168,6 +173,11 @@ final class DomainViewModel {
168 hstsPreloaded = nil 173 hstsPreloaded = nil
169 hstsLoading = false 174 hstsLoading = false
170 httpHeaders = [] 175 httpHeaders = []
176 httpSecurityGrade = nil
177 httpStatusCode = nil
178 httpResponseTimeMs = nil
179 httpProtocol = nil
180 http3Advertised = false
171 httpHeadersError = nil 181 httpHeadersError = nil
172 httpHeadersLoading = false 182 httpHeadersLoading = false
173 reachabilityResults = [] 183 reachabilityResults = []
@@ -210,6 +220,11 @@ final class DomainViewModel {
210 hstsPreloaded = nil 220 hstsPreloaded = nil
211 hstsLoading = true 221 hstsLoading = true
212 httpHeaders = [] 222 httpHeaders = []
223 httpSecurityGrade = nil
224 httpStatusCode = nil
225 httpResponseTimeMs = nil
226 httpProtocol = nil
227 http3Advertised = false
213 httpHeadersError = nil 228 httpHeadersError = nil
214 httpHeadersLoading = true 229 httpHeadersLoading = true
215 reachabilityResults = [] 230 reachabilityResults = []
@@ -300,8 +315,13 @@ final class DomainViewModel {
300 315
301 private func runHTTPHeaders(domain: String) async { 316 private func runHTTPHeaders(domain: String) async {
302 do { 317 do {
303 let headers = try await HTTPHeadersService.fetch(domain: domain) 318 let result = try await HTTPHeadersService.fetch(domain: domain)
304 httpHeaders = headers 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
305 } catch { 325 } catch {
306 httpHeadersError = error.localizedDescription 326 httpHeadersError = error.localizedDescription
307 } 327 }
@@ -381,6 +401,11 @@ final class DomainViewModel {
381 sslError: sslError, 401 sslError: sslError,
382 hstsPreloaded: hstsPreloaded, 402 hstsPreloaded: hstsPreloaded,
383 httpHeaders: httpHeaders, 403 httpHeaders: httpHeaders,
404 httpSecurityGrade: httpSecurityGrade,
405 httpStatusCode: httpStatusCode,
406 httpResponseTimeMs: httpResponseTimeMs,
407 httpProtocol: httpProtocol,
408 http3Advertised: http3Advertised,
384 httpHeadersError: httpHeadersError, 409 httpHeadersError: httpHeadersError,
385 reachabilityResults: reachabilityResults, 410 reachabilityResults: reachabilityResults,
386 ipGeolocation: ipGeolocation, 411 ipGeolocation: ipGeolocation,
@@ -400,6 +425,11 @@ final class DomainViewModel {
400 sslError: String? = nil, 425 sslError: String? = nil,
401 hstsPreloaded: Bool? = nil, 426 hstsPreloaded: Bool? = nil,
402 httpHeaders: [HTTPHeader], 427 httpHeaders: [HTTPHeader],
428 httpSecurityGrade: String? = nil,
429 httpStatusCode: Int? = nil,
430 httpResponseTimeMs: Int? = nil,
431 httpProtocol: String? = nil,
432 http3Advertised: Bool = false,
403 httpHeadersError: String? = nil, 433 httpHeadersError: String? = nil,
404 reachabilityResults: [PortReachability], 434 reachabilityResults: [PortReachability],
405 ipGeolocation: IPGeolocation?, 435 ipGeolocation: IPGeolocation?,
@@ -548,6 +578,21 @@ final class DomainViewModel {
548 for header in httpHeaders { 578 for header in httpHeaders {
549 lines.append(" \(header.name): \(header.value)") 579 lines.append(" \(header.name): \(header.value)")
550 } 580 }
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 }
551 } else if let error = httpHeadersError { 596 } else if let error = httpHeadersError {
552 lines.append("") 597 lines.append("")
553 lines.append("HTTP Headers") 598 lines.append("HTTP Headers")
DomainDig/HTTPHeadersService.swift +94 −6
@@ -1,22 +1,110 @@
1import Foundation 1import Foundation
2 2
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
3struct HTTPHeadersService { 37struct HTTPHeadersService {
4 static func fetch(domain: String) async throws -> [HTTPHeader] { 38 static func fetch(domain: String) async throws -> HTTPHeadersResult {
5 let url = URL(string: "https://\(domain)")! 39 let url = URL(string: "https://\(domain)")!
6 var request = URLRequest(url: url, timeoutInterval: 10) 40 var request = URLRequest(url: url, timeoutInterval: 10)
7 request.httpMethod = "HEAD" 41 request.httpMethod = "HEAD"
42 let metricsDelegate = TaskMetricsDelegate()
43 let startTime = Date()
8 44
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))
10 47
11 guard let httpResponse = response as? HTTPURLResponse else { 48 guard let httpResponse = response as? HTTPURLResponse else {
12 throw URLError(.badServerResponse) 49 throw URLError(.badServerResponse)
13 } 50 }
14 51
15 return httpResponse.allHeaderFields.compactMap { key, value in 52 let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in
16 guard let name = key as? String, 53 guard let name = entry.key as? String,
17 let val = value as? String else { return nil } 54 let value = entry.value as? String else { return nil }
18 return HTTPHeader(name: name, value: val) 55 return HTTPHeader(name: name, value: value)
19 } 56 }
20 .sorted { $0.name.lowercased() < $1.name.lowercased() } 57 .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
21 } 109 }
22} 110}