krz/domain-dig

an ios app for DNS & SSL analysis

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

v4.9.0: LocalAPIService.swift · raw

   1import Foundation
   2import Network
   3import Observation
   4import Security
   5
   6private let localAPIVersion = "v1"
   7
   8private enum LocalAPIServerError: LocalizedError {
   9    case missingSecret
  10    case secretPersistenceFailed
  11    case serverStartFailed(String)
  12
  13    var errorDescription: String? {
  14        switch self {
  15        case .missingSecret:
  16            return "Local API token is unavailable."
  17        case .secretPersistenceFailed:
  18            return "Could not store the Local API token in Keychain."
  19        case .serverStartFailed(let message):
  20            return message
  21        }
  22    }
  23}
  24
  25private final class ListenerResumeState: @unchecked Sendable {
  26    private let lock = NSLock()
  27    nonisolated(unsafe) private var hasResumed = false
  28
  29    nonisolated func beginResume() -> Bool {
  30        lock.lock()
  31        defer { lock.unlock() }
  32
  33        guard !hasResumed else { return false }
  34        hasResumed = true
  35        return true
  36    }
  37}
  38
  39@MainActor
  40@Observable
  41final class LocalAPIService {
  42    static let shared = LocalAPIService()
  43
  44    private static let configDefaultsKey = "localAPI.config"
  45    private static let logsDefaultsKey = "localAPI.requestLogs"
  46    private static let maxRequestLogs = 120
  47    private(set) var config: LocalAPIConfig
  48    private(set) var requestLogs: [APIRequestLog]
  49    private(set) var isRunning = false
  50    private(set) var boundPort: Int?
  51    private(set) var statusMessage: String?
  52
  53    private var server: LocalAPIServer?
  54
  55    private init() {
  56        config = Self.loadConfig()
  57        requestLogs = Self.loadRequestLogs()
  58    }
  59
  60    var address: String {
  61        "http://127.0.0.1:\(boundPort ?? config.port)"
  62    }
  63
  64    var maskedToken: String {
  65        guard !config.token.isEmpty else { return "Unavailable" }
  66        let suffix = String(config.token.suffix(8))
  67        return "••••••••\(suffix)"
  68    }
  69
  70    func refresh() {
  71        config = Self.loadConfig()
  72        requestLogs = Self.loadRequestLogs()
  73
  74        Task {
  75            if config.isEnabled {
  76                await startServerIfNeeded()
  77            } else {
  78                stopServer()
  79            }
  80        }
  81    }
  82
  83    func setEnabled(_ isEnabled: Bool) {
  84        config.isEnabled = isEnabled
  85        persistConfig()
  86
  87        Task {
  88            if isEnabled {
  89                await startServer(forceRestart: true)
  90            } else {
  91                stopServer()
  92            }
  93        }
  94    }
  95
  96    func setPort(_ port: Int) {
  97        let sanitizedPort = Self.sanitizedPort(port)
  98        guard config.port != sanitizedPort else { return }
  99        config.port = sanitizedPort
 100        persistConfig()
 101
 102        Task {
 103            guard config.isEnabled else { return }
 104            await startServer(forceRestart: true)
 105        }
 106    }
 107
 108    func setRequestLoggingEnabled(_ isEnabled: Bool) {
 109        config.requestLoggingEnabled = isEnabled
 110        persistConfig()
 111    }
 112
 113    func rotateToken() {
 114        config.token = Self.generateToken()
 115        persistConfig()
 116
 117        Task {
 118            guard config.isEnabled else { return }
 119            await startServer(forceRestart: true)
 120        }
 121    }
 122
 123    func stopServer() {
 124        server?.stop()
 125        server = nil
 126        isRunning = false
 127        boundPort = nil
 128        if config.isEnabled {
 129            statusMessage = "Stopped"
 130        } else {
 131            statusMessage = "Disabled"
 132        }
 133    }
 134
 135    func clearRequestLogs() {
 136        requestLogs.removeAll()
 137        Self.saveRequestLogs([])
 138    }
 139
 140    func copyToken() {
 141        guard !config.token.isEmpty else { return }
 142        AppClipboard.copy(config.token)
 143    }
 144
 145    func localSecretReferences() -> [String] {
 146        [LocalAPISecretStore.reference]
 147    }
 148
 149    func resetAfterLocalWipe() {
 150        stopServer()
 151        config = LocalAPIConfig(token: Self.generateToken())
 152        requestLogs = []
 153        persistConfig()
 154        clearRequestLogs()
 155    }
 156
 157    private func startServerIfNeeded() async {
 158        guard config.isEnabled else {
 159            stopServer()
 160            return
 161        }
 162        guard server == nil else { return }
 163        await startServer(forceRestart: false)
 164    }
 165
 166    private func startServer(forceRestart: Bool) async {
 167        if forceRestart {
 168            stopServer()
 169        }
 170
 171        guard config.isEnabled else {
 172            stopServer()
 173            return
 174        }
 175
 176        let token = config.token.isEmpty ? Self.generateToken() : config.token
 177        if token != config.token {
 178            config.token = token
 179            persistConfig()
 180        }
 181
 182        let server = LocalAPIServer(
 183            port: Self.sanitizedPort(config.port),
 184            token: token,
 185            requestLogger: { [weak self] log in
 186                guard let self else { return }
 187                Task { @MainActor in
 188                    self.record(log)
 189                }
 190            },
 191            stateLogger: { [weak self] stateMessage in
 192                guard let self else { return }
 193                Task { @MainActor in
 194                    self.statusMessage = stateMessage
 195                }
 196            }
 197        )
 198
 199        do {
 200            let activePort = try await server.start()
 201            self.server = server
 202            boundPort = activePort
 203            isRunning = true
 204            statusMessage = "Listening on localhost:\(activePort)"
 205        } catch {
 206            self.server = nil
 207            boundPort = nil
 208            isRunning = false
 209            statusMessage = error.localizedDescription
 210        }
 211    }
 212
 213    private func record(_ log: APIRequestLog) {
 214        guard config.requestLoggingEnabled else { return }
 215        requestLogs.insert(log, at: 0)
 216        requestLogs = Array(requestLogs.prefix(Self.maxRequestLogs))
 217        Self.saveRequestLogs(requestLogs)
 218    }
 219
 220    private func persistConfig() {
 221        config.port = Self.sanitizedPort(config.port)
 222        if config.token.isEmpty {
 223            config.token = Self.generateToken()
 224        }
 225
 226        Self.saveConfig(config)
 227    }
 228
 229    private static func loadConfig(defaults: UserDefaults = .standard) -> LocalAPIConfig {
 230        let persisted: PersistedLocalAPIConfig
 231        if let data = defaults.data(forKey: configDefaultsKey),
 232           let decoded = try? JSONDecoder().decode(PersistedLocalAPIConfig.self, from: data) {
 233            persisted = decoded
 234        } else {
 235            persisted = PersistedLocalAPIConfig()
 236        }
 237
 238        let token = (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) ?? generateToken()
 239        if (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) == nil {
 240            try? LocalAPISecretStore.save(secret: token, reference: LocalAPISecretStore.reference)
 241        }
 242
 243        return LocalAPIConfig(
 244            isEnabled: persisted.isEnabled,
 245            port: sanitizedPort(persisted.port),
 246            token: token,
 247            requestLoggingEnabled: persisted.requestLoggingEnabled
 248        )
 249    }
 250
 251    private static func saveConfig(_ config: LocalAPIConfig, defaults: UserDefaults = .standard) {
 252        let persisted = PersistedLocalAPIConfig(
 253            isEnabled: config.isEnabled,
 254            port: sanitizedPort(config.port),
 255            requestLoggingEnabled: config.requestLoggingEnabled
 256        )
 257
 258        if let data = try? JSONEncoder().encode(persisted) {
 259            defaults.set(data, forKey: configDefaultsKey)
 260        }
 261
 262        try? LocalAPISecretStore.save(secret: config.token, reference: LocalAPISecretStore.reference)
 263    }
 264
 265    private static func loadRequestLogs(defaults: UserDefaults = .standard) -> [APIRequestLog] {
 266        guard let data = defaults.data(forKey: logsDefaultsKey),
 267              let decoded = try? JSONDecoder().decode([APIRequestLog].self, from: data) else {
 268            return []
 269        }
 270        return decoded
 271    }
 272
 273    private static func saveRequestLogs(_ logs: [APIRequestLog], defaults: UserDefaults = .standard) {
 274        if let data = try? JSONEncoder().encode(Array(logs.prefix(maxRequestLogs))) {
 275            defaults.set(data, forKey: logsDefaultsKey)
 276        }
 277    }
 278
 279    private static func sanitizedPort(_ port: Int) -> Int {
 280        min(max(port, 1024), 65535)
 281    }
 282
 283    private static func generateToken() -> String {
 284        let bytes = (0..<24).map { _ in UInt8.random(in: .min ... .max) }
 285        let data = Data(bytes)
 286        return data
 287            .base64EncodedString()
 288            .replacingOccurrences(of: "+", with: "-")
 289            .replacingOccurrences(of: "/", with: "_")
 290            .replacingOccurrences(of: "=", with: "")
 291    }
 292
 293    private struct PersistedLocalAPIConfig: Codable {
 294        var isEnabled: Bool = false
 295        var port: Int = 47821
 296        var requestLoggingEnabled: Bool = true
 297    }
 298
 299    private enum LocalAPISecretStore {
 300        static let reference = "DomainDig.LocalAPI.Token"
 301
 302        static func save(secret: String, reference: String) throws {
 303            let data = Data(secret.utf8)
 304            try? delete(reference: reference)
 305
 306            let query: [String: Any] = [
 307                kSecClass as String: kSecClassGenericPassword,
 308                kSecAttrAccount as String: reference,
 309                kSecValueData as String: data,
 310                kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock
 311            ]
 312
 313            let status = SecItemAdd(query as CFDictionary, nil)
 314            guard status == errSecSuccess else {
 315                throw LocalAPIServerError.secretPersistenceFailed
 316            }
 317        }
 318
 319        static func secret(reference: String) throws -> String {
 320            let query: [String: Any] = [
 321                kSecClass as String: kSecClassGenericPassword,
 322                kSecAttrAccount as String: reference,
 323                kSecReturnData as String: true,
 324                kSecMatchLimit as String: kSecMatchLimitOne
 325            ]
 326
 327            var result: CFTypeRef?
 328            let status = SecItemCopyMatching(query as CFDictionary, &result)
 329            guard status == errSecSuccess,
 330                  let data = result as? Data,
 331                  let secret = String(data: data, encoding: .utf8) else {
 332                throw LocalAPIServerError.missingSecret
 333            }
 334
 335            return secret
 336        }
 337
 338        static func delete(reference: String) throws {
 339            let query: [String: Any] = [
 340                kSecClass as String: kSecClassGenericPassword,
 341                kSecAttrAccount as String: reference
 342            ]
 343            SecItemDelete(query as CFDictionary)
 344        }
 345    }
 346
 347}
 348
 349private final class LocalAPIServer: @unchecked Sendable {
 350    private let port: Int
 351    private let token: String
 352    private let requestLogger: @Sendable (APIRequestLog) -> Void
 353    private let stateLogger: @Sendable (String) -> Void
 354    private let handler = LocalAPIRequestHandler()
 355    private let queue = DispatchQueue(label: "DomainDig.LocalAPIServer")
 356
 357    private var listener: NWListener?
 358
 359    init(
 360        port: Int,
 361        token: String,
 362        requestLogger: @escaping @Sendable (APIRequestLog) -> Void,
 363        stateLogger: @escaping @Sendable (String) -> Void
 364    ) {
 365        self.port = port
 366        self.token = token
 367        self.requestLogger = requestLogger
 368        self.stateLogger = stateLogger
 369    }
 370
 371    func start() async throws -> Int {
 372        let parameters = NWParameters.tcp
 373        parameters.acceptLocalOnly = true
 374        parameters.allowLocalEndpointReuse = true
 375        parameters.requiredLocalEndpoint = .hostPort(
 376            host: .ipv4(IPv4Address.loopback),
 377            port: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any
 378        )
 379
 380        let listener: NWListener
 381        do {
 382            listener = try NWListener(
 383                using: parameters,
 384                on: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any
 385            )
 386        } catch {
 387            throw LocalAPIServerError.serverStartFailed("Could not start Local API on port \(port).")
 388        }
 389
 390        listener.newConnectionHandler = { [weak self] connection in
 391            Task {
 392                await self?.handle(connection: connection)
 393            }
 394        }
 395
 396        return try await withCheckedThrowingContinuation { continuation in
 397            let resumeState = ListenerResumeState()
 398
 399            listener.stateUpdateHandler = { state in
 400                switch state {
 401                case .ready:
 402                    guard resumeState.beginResume() else { return }
 403                    continuation.resume(returning: Int(listener.port?.rawValue ?? UInt16(self.port)))
 404                case .failed(let error):
 405                    self.stateLogger("Failed: \(error.localizedDescription)")
 406                    guard resumeState.beginResume() else { return }
 407                    continuation.resume(throwing: LocalAPIServerError.serverStartFailed(error.localizedDescription))
 408                case .cancelled:
 409                    self.stateLogger("Stopped")
 410                default:
 411                    break
 412                }
 413            }
 414
 415            self.listener = listener
 416            listener.start(queue: self.queue)
 417        }
 418    }
 419
 420    func stop() {
 421        listener?.cancel()
 422        listener = nil
 423    }
 424
 425    private func handle(connection: NWConnection) async {
 426        connection.start(queue: queue)
 427        let startedAt = Date()
 428
 429        do {
 430            let requestData = try await readCompleteRequest(from: connection)
 431            let request = try LocalAPIHTTPParser.parseRequest(from: requestData)
 432            let response = await handler.handle(request: request, expectedToken: token)
 433            let log = APIRequestLog(
 434                timestamp: startedAt,
 435                method: request.method,
 436                path: request.pathWithQuery,
 437                statusCode: response.statusCode,
 438                duration: Date().timeIntervalSince(startedAt)
 439            )
 440            requestLogger(log)
 441            try await send(response.serialized(), on: connection)
 442        } catch let error as LocalAPIHTTPParser.ParserError {
 443            let response = LocalAPIHTTPResponse.error(statusCode: 400, code: "bad_request", message: error.localizedDescription)
 444            let log = APIRequestLog(
 445                timestamp: startedAt,
 446                method: "INVALID",
 447                path: "/",
 448                statusCode: response.statusCode,
 449                duration: Date().timeIntervalSince(startedAt)
 450            )
 451            requestLogger(log)
 452            try? await send(response.serialized(), on: connection)
 453        } catch {
 454            let response = LocalAPIHTTPResponse.error(statusCode: 500, code: "internal_error", message: "The Local API request failed.")
 455            let log = APIRequestLog(
 456                timestamp: startedAt,
 457                method: "ERROR",
 458                path: "/",
 459                statusCode: response.statusCode,
 460                duration: Date().timeIntervalSince(startedAt)
 461            )
 462            requestLogger(log)
 463            try? await send(response.serialized(), on: connection)
 464        }
 465
 466        connection.cancel()
 467    }
 468
 469    private func readCompleteRequest(from connection: NWConnection, accumulated: Data = Data()) async throws -> Data {
 470        let chunk = try await receiveChunk(from: connection)
 471        let combined = accumulated + chunk.data
 472
 473        if LocalAPIHTTPParser.isCompleteRequest(combined) || chunk.isComplete {
 474            return combined
 475        }
 476
 477        return try await readCompleteRequest(from: connection, accumulated: combined)
 478    }
 479
 480    private func receiveChunk(from connection: NWConnection) async throws -> (data: Data, isComplete: Bool) {
 481        try await withCheckedThrowingContinuation { continuation in
 482            connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
 483                if let error {
 484                    continuation.resume(throwing: error)
 485                    return
 486                }
 487
 488                continuation.resume(returning: (data ?? Data(), isComplete))
 489            }
 490        }
 491    }
 492
 493    private func send(_ data: Data, on connection: NWConnection) async throws {
 494        try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
 495            connection.send(content: data, completion: .contentProcessed { error in
 496                if let error {
 497                    continuation.resume(throwing: error)
 498                } else {
 499                    continuation.resume(returning: ())
 500                }
 501            })
 502        }
 503    }
 504}
 505
 506private struct LocalAPIRequestHandler {
 507    private let encoder: JSONEncoder = {
 508        let encoder = JSONEncoder()
 509        encoder.dateEncodingStrategy = .iso8601
 510        encoder.outputFormatting = [.sortedKeys]
 511        return encoder
 512    }()
 513
 514    private let decoder: JSONDecoder = {
 515        let decoder = JSONDecoder()
 516        decoder.dateDecodingStrategy = .iso8601
 517        return decoder
 518    }()
 519
 520    private let inspectionService = DomainInspectionService()
 521    private let reportBuilder = DomainReportBuilder()
 522
 523    func handle(request: LocalAPIHTTPRequest, expectedToken: String) async -> LocalAPIHTTPResponse {
 524        guard isAuthorized(request: request, expectedToken: expectedToken) else {
 525            return .error(statusCode: 401, code: "unauthorized", message: "A valid local API token is required.")
 526        }
 527
 528        if request.method == "GET", request.pathComponents == ["portfolio"] {
 529            return successResponse(PortfolioPayload(summary: buildPortfolioSummary()))
 530        }
 531
 532        if request.method == "GET", request.pathComponents == ["domains"] {
 533            return successResponse(DomainListPayload(domains: DomainDataPortabilityService.loadTrackedDomains()))
 534        }
 535
 536        if request.method == "GET",
 537           request.pathComponents.count == 2,
 538           request.pathComponents.first == "domains" {
 539            let domain = request.pathComponents[1]
 540            return domainDetailsResponse(for: domain)
 541        }
 542
 543        if request.method == "GET",
 544           request.pathComponents.count == 3,
 545           request.pathComponents.first == "domains",
 546           request.pathComponents.last == "history" {
 547            let domain = request.pathComponents[1]
 548            return successResponse(DomainHistoryPayload(domain: normalizedDomain(domain), history: historyEntries(for: domain)))
 549        }
 550
 551        if request.method == "GET", request.pathComponents == ["events"] {
 552            return successResponse(RecentEventsPayload(events: recentEvents()))
 553        }
 554
 555        if request.method == "GET", request.pathComponents == ["monitoring"] {
 556            return successResponse(monitoringPayload())
 557        }
 558
 559        if request.method == "POST", request.pathComponents == ["inspect"] {
 560            return await inspectBodyResponse(request.body)
 561        }
 562
 563        if request.method == "POST",
 564           request.pathComponents.count == 2,
 565           request.pathComponents.first == "inspect" {
 566            let domain = request.pathComponents[1]
 567            return await inspectResponse(for: domain)
 568        }
 569
 570        if request.method == "POST",
 571           request.pathComponents.count == 3,
 572           request.pathComponents.first == "monitoring",
 573           request.pathComponents.last == "enable" {
 574            let domain = request.pathComponents[1]
 575            return await setMonitoring(enabled: true, for: domain)
 576        }
 577
 578        if request.method == "POST",
 579           request.pathComponents.count == 3,
 580           request.pathComponents.first == "monitoring",
 581           request.pathComponents.last == "disable" {
 582            let domain = request.pathComponents[1]
 583            return await setMonitoring(enabled: false, for: domain)
 584        }
 585
 586        return .error(statusCode: 404, code: "not_found", message: "The requested Local API route does not exist.")
 587    }
 588
 589    private func isAuthorized(request: LocalAPIHTTPRequest, expectedToken: String) -> Bool {
 590        guard !expectedToken.isEmpty else { return false }
 591
 592        if request.headers["x-api-token"] == expectedToken {
 593            return true
 594        }
 595
 596        if let authorization = request.headers["authorization"],
 597           authorization == "Bearer \(expectedToken)" {
 598            return true
 599        }
 600
 601        return false
 602    }
 603
 604    private func successResponse<Value: Encodable>(_ value: Value) -> LocalAPIHTTPResponse {
 605        let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: localAPIVersion)
 606        guard let body = try? encoder.encode(envelope) else {
 607            return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.")
 608        }
 609
 610        return LocalAPIHTTPResponse(statusCode: 200, body: body)
 611    }
 612
 613    private func domainDetailsResponse(for domain: String) -> LocalAPIHTTPResponse {
 614        let normalized = normalizedDomain(domain)
 615        let trackedDomain = DomainDataPortabilityService.loadTrackedDomains().first {
 616            $0.domain.caseInsensitiveCompare(normalized) == .orderedSame
 617        }
 618        let history = historyEntries(for: normalized)
 619        let latestEntry = history.first
 620
 621        guard trackedDomain != nil || latestEntry != nil else {
 622            return .error(statusCode: 404, code: "domain_not_found", message: "No local data exists for \(normalized).")
 623        }
 624
 625        let latestReport = latestEntry.map { buildReport(for: $0, from: history) }
 626        return successResponse(
 627            DomainDetailPayload(
 628                domain: normalized,
 629                trackedDomain: trackedDomain,
 630                latestReport: latestReport
 631            )
 632        )
 633    }
 634
 635    private func inspectBodyResponse(_ body: Data) async -> LocalAPIHTTPResponse {
 636        guard let payload = try? decoder.decode(InspectRequestPayload.self, from: body) else {
 637            return .error(statusCode: 400, code: "invalid_body", message: "Expected JSON body: {\"domain\":\"example.com\"}.")
 638        }
 639
 640        return await inspectResponse(for: payload.domain)
 641    }
 642
 643    private func inspectResponse(for domain: String) async -> LocalAPIHTTPResponse {
 644        let normalized = normalizedDomain(domain)
 645        guard !normalized.isEmpty else {
 646            return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.")
 647        }
 648
 649        let previousSnapshot = latestHistoryEntry(for: normalized)?.snapshot
 650        let report = await inspectionService.inspect(domain: normalized, previousSnapshot: previousSnapshot)
 651        return successResponse(InspectResponsePayload(report: report))
 652    }
 653
 654    private func setMonitoring(enabled: Bool, for domain: String) async -> LocalAPIHTTPResponse {
 655        let normalized = normalizedDomain(domain)
 656        guard !normalized.isEmpty else {
 657            return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.")
 658        }
 659
 660        var trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
 661        guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame }) else {
 662            return .error(statusCode: 404, code: "domain_not_found", message: "Tracked domain \(normalized) was not found.")
 663        }
 664
 665        trackedDomains[index].monitoringEnabled = enabled
 666        trackedDomains[index].updatedAt = Date()
 667        DomainDataPortabilityService.saveTrackedDomains(trackedDomains)
 668
 669        let sanitizedSettings = MonitoringStorage.sanitizeSettings(
 670            MonitoringStorage.loadSettings(),
 671            trackedDomains: trackedDomains
 672        )
 673        MonitoringStorage.saveSettings(sanitizedSettings)
 674
 675        await MainActor.run {
 676            _ = DomainMonitoringScheduler.shared.syncSchedule()
 677        }
 678
 679        return successResponse(
 680            MonitoringMutationPayload(
 681                domain: trackedDomains[index].domain,
 682                monitoringEnabled: trackedDomains[index].monitoringEnabled
 683            )
 684        )
 685    }
 686
 687    private func buildPortfolioSummary() -> PortfolioSummary {
 688        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
 689        let history = DomainDataPortabilityService.loadHistoryEntries()
 690
 691        var healthyCount = 0
 692        var warningCount = 0
 693        var criticalCount = 0
 694        var changedLast24h = 0
 695        var unreachableCount = 0
 696
 697        for trackedDomain in trackedDomains {
 698            guard let latestEntry = latestHistoryEntry(for: trackedDomain.domain, history: history) else { continue }
 699            let report = buildReport(for: latestEntry, from: history)
 700
 701            switch report.health {
 702            case .healthy:
 703                healthyCount += 1
 704            case .warning:
 705                warningCount += 1
 706            case .critical:
 707                criticalCount += 1
 708            }
 709
 710            if latestEntry.changeSummary?.hasChanges == true,
 711               Date().timeIntervalSince(latestEntry.timestamp) <= 24 * 60 * 60 {
 712                changedLast24h += 1
 713            }
 714
 715            if report.network.reachabilityError != nil || report.lastMonitoringFailure != nil {
 716                unreachableCount += 1
 717            }
 718        }
 719
 720        return PortfolioSummary(
 721            totalDomains: trackedDomains.count,
 722            healthyCount: healthyCount,
 723            warningCount: warningCount,
 724            criticalCount: criticalCount,
 725            changedLast24h: changedLast24h,
 726            expiringSoonCount: trackedDomains.filter { $0.certificateWarningLevel != .none }.count,
 727            unreachableCount: unreachableCount
 728        )
 729    }
 730
 731    private func monitoringPayload() -> MonitoringPayload {
 732        let trackedDomains = DomainDataPortabilityService.loadTrackedDomains()
 733        let settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains)
 734
 735        return MonitoringPayload(
 736            isEnabled: settings.isEnabled,
 737            scope: settings.scope,
 738            alertsEnabled: settings.alertsEnabled,
 739            monitoredDomains: trackedDomains.map {
 740                MonitoringDomainPayload(
 741                    domain: $0.domain,
 742                    monitoringEnabled: $0.monitoringEnabled,
 743                    lastMonitoredAt: $0.lastMonitoredAt,
 744                    lastAlertAt: $0.lastAlertAt,
 745                    certificateWarningLevel: $0.certificateWarningLevel
 746                )
 747            }
 748        )
 749    }
 750
 751    private func recentEvents() -> [RecentEventPayload] {
 752        MonitoringStorage.loadLogs()
 753            .prefix(25)
 754            .flatMap { log in
 755                log.checkedDomains.map { result in
 756                    RecentEventPayload(
 757                        timestamp: result.checkedAt,
 758                        domain: result.domain,
 759                        summary: result.summaryMessage,
 760                        status: result.errorMessage == nil ? "ok" : "error",
 761                        severity: result.alertSeverity?.title ?? "Info"
 762                    )
 763                }
 764            }
 765    }
 766
 767    private func historyEntries(for domain: String) -> [HistoryEntry] {
 768        let normalized = normalizedDomain(domain)
 769        return DomainDataPortabilityService.loadHistoryEntries().filter {
 770            $0.domain.caseInsensitiveCompare(normalized) == .orderedSame
 771        }
 772    }
 773
 774    private func latestHistoryEntry(for domain: String, history: [HistoryEntry]? = nil) -> HistoryEntry? {
 775        let entries = history ?? DomainDataPortabilityService.loadHistoryEntries()
 776        let normalized = normalizedDomain(domain)
 777        return entries.first { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame }
 778    }
 779
 780    private func buildReport(for entry: HistoryEntry, from history: [HistoryEntry]) -> DomainReport {
 781        let previousSnapshot = history
 782            .dropFirst(history.firstIndex(where: { $0.id == entry.id }).map { $0 + 1 } ?? history.count)
 783            .first { $0.domain.caseInsensitiveCompare(entry.domain) == .orderedSame }?
 784            .snapshot
 785        return reportBuilder.build(from: entry, previousSnapshot: previousSnapshot)
 786    }
 787
 788    private func normalizedDomain(_ value: String) -> String {
 789        value
 790            .trimmingCharacters(in: .whitespacesAndNewlines)
 791            .replacingOccurrences(of: "https://", with: "")
 792            .replacingOccurrences(of: "http://", with: "")
 793            .components(separatedBy: "/")
 794            .first?
 795            .lowercased() ?? ""
 796    }
 797}
 798
 799private struct LocalAPIHTTPRequest {
 800    let method: String
 801    let path: String
 802    let queryItems: [URLQueryItem]
 803    let headers: [String: String]
 804    let body: Data
 805
 806    var pathComponents: [String] {
 807        path.split(separator: "/").map(String.init)
 808    }
 809
 810    var pathWithQuery: String {
 811        guard !queryItems.isEmpty else { return path }
 812        let query = queryItems.compactMap { item in
 813            guard let value = item.value else { return item.name }
 814            return "\(item.name)=\(value)"
 815        }
 816        .joined(separator: "&")
 817        return "\(path)?\(query)"
 818    }
 819}
 820
 821private struct LocalAPIHTTPResponse {
 822    let statusCode: Int
 823    let body: Data
 824
 825    init(statusCode: Int, body: Data) {
 826        self.statusCode = statusCode
 827        self.body = body
 828    }
 829
 830    static func error(statusCode: Int, code: String, message: String) -> LocalAPIHTTPResponse {
 831        let payload = LocalAPIEnvelope<EmptyPayload>(
 832            success: false,
 833            data: nil,
 834            error: LocalAPIErrorPayload(code: code, message: message),
 835            version: localAPIVersion
 836        )
 837
 838        let encoder = JSONEncoder()
 839        encoder.outputFormatting = [.sortedKeys]
 840        let body = (try? encoder.encode(payload)) ?? Data()
 841        return LocalAPIHTTPResponse(statusCode: statusCode, body: body)
 842    }
 843
 844    func serialized() -> Data {
 845        var response = Data()
 846        response.append("HTTP/1.1 \(statusCode) \(Self.reasonPhrase(for: statusCode))\r\n".data(using: .utf8) ?? Data())
 847        response.append("Content-Type: application/json\r\n".data(using: .utf8) ?? Data())
 848        response.append("Content-Length: \(body.count)\r\n".data(using: .utf8) ?? Data())
 849        response.append("Connection: close\r\n\r\n".data(using: .utf8) ?? Data())
 850        response.append(body)
 851        return response
 852    }
 853
 854    private static func reasonPhrase(for statusCode: Int) -> String {
 855        switch statusCode {
 856        case 200:
 857            return "OK"
 858        case 400:
 859            return "Bad Request"
 860        case 401:
 861            return "Unauthorized"
 862        case 404:
 863            return "Not Found"
 864        default:
 865            return "Internal Server Error"
 866        }
 867    }
 868}
 869
 870private enum LocalAPIHTTPParser {
 871    enum ParserError: LocalizedError {
 872        case invalidRequestLine
 873        case invalidPath
 874
 875        var errorDescription: String? {
 876            switch self {
 877            case .invalidRequestLine:
 878                return "The HTTP request line is invalid."
 879            case .invalidPath:
 880                return "The HTTP request path is invalid."
 881            }
 882        }
 883    }
 884
 885    static func isCompleteRequest(_ data: Data) -> Bool {
 886        guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else {
 887            return false
 888        }
 889
 890        let headerData = data[..<separatorRange.lowerBound]
 891        guard let headerString = String(data: headerData, encoding: .utf8) else {
 892            return false
 893        }
 894
 895        let contentLength = headerString
 896            .split(separator: "\r\n")
 897            .first { $0.lowercased().hasPrefix("content-length:") }
 898            .flatMap { Int($0.split(separator: ":", maxSplits: 1).last?.trimmingCharacters(in: .whitespaces) ?? "") }
 899            ?? 0
 900
 901        let bodyLength = data.count - separatorRange.upperBound
 902        return bodyLength >= contentLength
 903    }
 904
 905    static func parseRequest(from data: Data) throws -> LocalAPIHTTPRequest {
 906        guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else {
 907            throw ParserError.invalidRequestLine
 908        }
 909
 910        let headerData = data[..<separatorRange.lowerBound]
 911        let bodyData = data[separatorRange.upperBound...]
 912
 913        guard let headerString = String(data: headerData, encoding: .utf8) else {
 914            throw ParserError.invalidRequestLine
 915        }
 916
 917        let lines = headerString.split(separator: "\r\n", omittingEmptySubsequences: false)
 918        guard let requestLine = lines.first else {
 919            throw ParserError.invalidRequestLine
 920        }
 921
 922        let requestParts = requestLine.split(separator: " ")
 923        guard requestParts.count >= 2 else {
 924            throw ParserError.invalidRequestLine
 925        }
 926
 927        let method = String(requestParts[0]).uppercased()
 928        let rawPath = String(requestParts[1])
 929        guard let components = URLComponents(string: rawPath) else {
 930            throw ParserError.invalidPath
 931        }
 932
 933        var headers: [String: String] = [:]
 934        for line in lines.dropFirst() {
 935            let parts = line.split(separator: ":", maxSplits: 1)
 936            guard parts.count == 2 else { continue }
 937            headers[String(parts[0]).lowercased()] = String(parts[1]).trimmingCharacters(in: .whitespaces)
 938        }
 939
 940        return LocalAPIHTTPRequest(
 941            method: method,
 942            path: components.path,
 943            queryItems: components.queryItems ?? [],
 944            headers: headers,
 945            body: Data(bodyData)
 946        )
 947    }
 948}
 949
 950private struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable {
 951    let success: Bool
 952    let data: DataPayload?
 953    let error: LocalAPIErrorPayload?
 954    let version: String
 955}
 956
 957private struct LocalAPIErrorPayload: Encodable {
 958    let code: String
 959    let message: String
 960}
 961
 962private struct EmptyPayload: Encodable {}
 963
 964private struct PortfolioPayload: Encodable {
 965    let summary: PortfolioSummary
 966}
 967
 968private struct PortfolioSummary: Encodable {
 969    let totalDomains: Int
 970    let healthyCount: Int
 971    let warningCount: Int
 972    let criticalCount: Int
 973    let changedLast24h: Int
 974    let expiringSoonCount: Int
 975    let unreachableCount: Int
 976}
 977
 978private struct DomainListPayload: Encodable {
 979    let domains: [TrackedDomain]
 980}
 981
 982private struct DomainDetailPayload: Encodable {
 983    let domain: String
 984    let trackedDomain: TrackedDomain?
 985    let latestReport: DomainReport?
 986}
 987
 988private struct DomainHistoryPayload: Encodable {
 989    let domain: String
 990    let history: [HistoryEntry]
 991}
 992
 993private struct RecentEventsPayload: Encodable {
 994    let events: [RecentEventPayload]
 995}
 996
 997private struct RecentEventPayload: Encodable {
 998    let timestamp: Date
 999    let domain: String
1000    let summary: String
1001    let status: String
1002    let severity: String
1003}
1004
1005private struct MonitoringPayload: Encodable {
1006    let isEnabled: Bool
1007    let scope: MonitoringScope
1008    let alertsEnabled: Bool
1009    let monitoredDomains: [MonitoringDomainPayload]
1010}
1011
1012private struct MonitoringDomainPayload: Encodable {
1013    let domain: String
1014    let monitoringEnabled: Bool
1015    let lastMonitoredAt: Date?
1016    let lastAlertAt: Date?
1017    let certificateWarningLevel: CertificateWarningLevel
1018}
1019
1020private struct MonitoringMutationPayload: Encodable {
1021    let domain: String
1022    let monitoringEnabled: Bool
1023}
1024
1025private struct InspectRequestPayload: Decodable {
1026    let domain: String
1027}
1028
1029private struct InspectResponsePayload: Encodable {
1030    let report: DomainReport
1031}