diff options
Diffstat (limited to 'DomainDig')
| -rw-r--r-- | DomainDig/ContentView.swift | 70 | ||||
| -rw-r--r-- | DomainDig/DomainViewModel.swift | 49 | ||||
| -rw-r--r-- | 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 } } |
