krz/domain-dig

an ios app for DNS & SSL analysis

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

v5.0.2: 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    // Written from the session's delegate queue, read only after the request
107    // has completed  URLSession guarantees didFinishCollecting is delivered
108    // before the task finishes, so the accesses are sequenced. (#27)
109    nonisolated(unsafe) private(set) var metrics: URLSessionTaskMetrics?
110
111    func urlSession(
112        _ _: URLSession,
113        task _: URLSessionTask,
114        didFinishCollecting metrics: URLSessionTaskMetrics
115    ) {
116        self.metrics = metrics
117    }
118}