krz/domain-dig

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

main: LocalAPIService.swift · raw

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