krz/domain-dig
an ios app for DNS & SSL analysis
clone: git clone https://gitbay.org/krz/domain-dig.git
v4.7.0: DomainDig/HTTPHeadersService.swift · raw
1import Foundation
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
37struct HTTPHeadersService {
38 static func fetch(domain: String) async -> ServiceResult<HTTPHeadersResult> {
39 let url = URL(string: "https://\(domain)")!
40 var request = URLRequest(url: url, timeoutInterval: 10)
41 request.httpMethod = "HEAD"
42 let metricsDelegate = TaskMetricsDelegate()
43 let startTime = Date()
44
45 do {
46 let (_, response) = try await URLSession.shared.data(for: request, delegate: metricsDelegate)
47 let responseTimeMs = max(0, Int(Date().timeIntervalSince(startTime) * 1000))
48
49 guard let httpResponse = response as? HTTPURLResponse else {
50 return .error(URLError(.badServerResponse).localizedDescription)
51 }
52
53 let headers = httpResponse.allHeaderFields.compactMap { entry -> HTTPHeader? in
54 guard let name = entry.key as? String,
55 let value = entry.value as? String else { return nil }
56 return HTTPHeader(name: name, value: value)
57 }
58 .sorted { $0.name.lowercased() < $1.name.lowercased() }
59
60 let networkProtocolName = metricsDelegate.metrics?.transactionMetrics
61 .compactMap { $0.networkProtocolName }
62 .last
63 let detectedProtocol = protocolLabel(for: networkProtocolName)
64 let altSvcValue = headerValue(named: "alt-svc", in: httpResponse)
65 let http3Advertised = altSvcValue?.localizedCaseInsensitiveContains("h3") == true
66 let result = HTTPHeadersResult(
67 headers: headers,
68 statusCode: httpResponse.statusCode,
69 responseTimeMs: responseTimeMs,
70 httpProtocol: detectedProtocol,
71 http3Advertised: http3Advertised
72 )
73
74 return headers.isEmpty ? .empty("No HTTP headers returned") : .success(result)
75 } catch {
76 return .error(error.localizedDescription)
77 }
78 }
79
80 private static func protocolLabel(for networkProtocolName: String?) -> String? {
81 guard let networkProtocolName else { return nil }
82
83 let normalized = networkProtocolName.lowercased()
84 if normalized == "h2" {
85 return "HTTP/2"
86 }
87 if normalized == "h3" || normalized.hasPrefix("quic") {
88 return "HTTP/3"
89 }
90 if normalized.hasPrefix("http/") {
91 return normalized.uppercased()
92 }
93
94 return networkProtocolName.uppercased()
95 }
96
97 private static func headerValue(named name: String, in response: HTTPURLResponse) -> String? {
98 response.allHeaderFields.first { key, _ in
99 guard let headerName = key as? String else { return false }
100 return headerName.caseInsensitiveCompare(name) == .orderedSame
101 }?.value as? String
102 }
103}
104
105private final class TaskMetricsDelegate: NSObject, URLSessionTaskDelegate {
106 private(set) var metrics: URLSessionTaskMetrics?
107
108 func urlSession(
109 _ _: URLSession,
110 task _: URLSessionTask,
111 didFinishCollecting metrics: URLSessionTaskMetrics
112 ) {
113 self.metrics = metrics
114 }
115}