krz/domain-dig

an ios app for DNS & SSL analysis

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

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