From cc69cbd7e589ec4b065ce74d8c5e7714a040cfc5 Mon Sep 17 00:00:00 2001 From: Christian Cleberg Date: Sun, 26 Apr 2026 00:22:01 -0500 Subject: DomainDig v4.2.0: Add a local-only HTTP API layer for DomainDig Expose DomainDig as a programmable local domain intelligence engine over localhost with explicit user opt-in and token-based authentication. Highlights: - add a localhost-only API server with safe start/stop lifecycle - require a local token for every request and store it in Keychain - add read endpoints for portfolio, domains, history, events, and monitoring - add inspection endpoints that reuse the existing inspection engine - add monitoring enable/disable control endpoints - add structured JSON response envelopes with API versioning - add lightweight capped request logging with clear/reset support - add a Settings UI for Local API enablement, token management, status, and logs - start the server on launch only when enabled - include Local API secrets in local data reset cleanup This is the first programmability release for DomainDig and establishes the foundation for Shortcuts, scripts, and other local automation workflows. --- LocalAPIService.swift | 1029 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 1029 insertions(+) create mode 100644 LocalAPIService.swift (limited to 'LocalAPIService.swift') diff --git a/LocalAPIService.swift b/LocalAPIService.swift new file mode 100644 index 0000000..ad1e07d --- /dev/null +++ b/LocalAPIService.swift @@ -0,0 +1,1029 @@ +import Foundation +import Network +import Observation +import Security + +private let localAPIVersion = "v1" + +private enum LocalAPIServerError: LocalizedError { + case missingSecret + case secretPersistenceFailed + case serverStartFailed(String) + + var errorDescription: String? { + switch self { + case .missingSecret: + return "Local API token is unavailable." + case .secretPersistenceFailed: + return "Could not store the Local API token in Keychain." + case .serverStartFailed(let message): + return message + } + } +} + +private final class ListenerResumeState: @unchecked Sendable { + private let lock = NSLock() + nonisolated(unsafe) private var hasResumed = false + + nonisolated func beginResume() -> Bool { + lock.lock() + defer { lock.unlock() } + + guard !hasResumed else { return false } + hasResumed = true + return true + } +} + +@MainActor +@Observable +final class LocalAPIService { + static let shared = LocalAPIService() + + private static let configDefaultsKey = "localAPI.config" + private static let logsDefaultsKey = "localAPI.requestLogs" + private static let maxRequestLogs = 120 + private(set) var config: LocalAPIConfig + private(set) var requestLogs: [APIRequestLog] + private(set) var isRunning = false + private(set) var boundPort: Int? + private(set) var statusMessage: String? + + private var server: LocalAPIServer? + + private init() { + config = Self.loadConfig() + requestLogs = Self.loadRequestLogs() + } + + var address: String { + "http://127.0.0.1:\(boundPort ?? config.port)" + } + + var maskedToken: String { + guard !config.token.isEmpty else { return "Unavailable" } + let suffix = String(config.token.suffix(8)) + return "••••••••\(suffix)" + } + + func refresh() { + config = Self.loadConfig() + requestLogs = Self.loadRequestLogs() + + Task { + if config.isEnabled { + await startServerIfNeeded() + } else { + stopServer() + } + } + } + + func setEnabled(_ isEnabled: Bool) { + config.isEnabled = isEnabled + persistConfig() + + Task { + if isEnabled { + await startServer(forceRestart: true) + } else { + stopServer() + } + } + } + + func setPort(_ port: Int) { + let sanitizedPort = Self.sanitizedPort(port) + guard config.port != sanitizedPort else { return } + config.port = sanitizedPort + persistConfig() + + Task { + guard config.isEnabled else { return } + await startServer(forceRestart: true) + } + } + + func setRequestLoggingEnabled(_ isEnabled: Bool) { + config.requestLoggingEnabled = isEnabled + persistConfig() + } + + func rotateToken() { + config.token = Self.generateToken() + persistConfig() + + Task { + guard config.isEnabled else { return } + await startServer(forceRestart: true) + } + } + + func stopServer() { + server?.stop() + server = nil + isRunning = false + boundPort = nil + if config.isEnabled { + statusMessage = "Stopped" + } else { + statusMessage = "Disabled" + } + } + + func clearRequestLogs() { + requestLogs.removeAll() + Self.saveRequestLogs([]) + } + + func copyToken() { + guard !config.token.isEmpty else { return } + AppClipboard.copy(config.token) + } + + func localSecretReferences() -> [String] { + [LocalAPISecretStore.reference] + } + + func resetAfterLocalWipe() { + stopServer() + config = LocalAPIConfig(token: Self.generateToken()) + requestLogs = [] + persistConfig() + clearRequestLogs() + } + + private func startServerIfNeeded() async { + guard config.isEnabled else { + stopServer() + return + } + guard server == nil else { return } + await startServer(forceRestart: false) + } + + private func startServer(forceRestart: Bool) async { + if forceRestart { + stopServer() + } + + guard config.isEnabled else { + stopServer() + return + } + + let token = config.token.isEmpty ? Self.generateToken() : config.token + if token != config.token { + config.token = token + persistConfig() + } + + let server = LocalAPIServer( + port: Self.sanitizedPort(config.port), + token: token, + requestLogger: { log in + Task { @MainActor [weak self] in + self?.record(log) + } + }, + stateLogger: { stateMessage in + Task { @MainActor [weak self] in + self?.statusMessage = stateMessage + } + } + ) + + do { + let activePort = try await server.start() + self.server = server + boundPort = activePort + isRunning = true + statusMessage = "Listening on localhost:\(activePort)" + } catch { + self.server = nil + boundPort = nil + isRunning = false + statusMessage = error.localizedDescription + } + } + + private func record(_ log: APIRequestLog) { + guard config.requestLoggingEnabled else { return } + requestLogs.insert(log, at: 0) + requestLogs = Array(requestLogs.prefix(Self.maxRequestLogs)) + Self.saveRequestLogs(requestLogs) + } + + private func persistConfig() { + config.port = Self.sanitizedPort(config.port) + if config.token.isEmpty { + config.token = Self.generateToken() + } + + Self.saveConfig(config) + } + + private static func loadConfig(defaults: UserDefaults = .standard) -> LocalAPIConfig { + let persisted: PersistedLocalAPIConfig + if let data = defaults.data(forKey: configDefaultsKey), + let decoded = try? JSONDecoder().decode(PersistedLocalAPIConfig.self, from: data) { + persisted = decoded + } else { + persisted = PersistedLocalAPIConfig() + } + + let token = (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) ?? generateToken() + if (try? LocalAPISecretStore.secret(reference: LocalAPISecretStore.reference)) == nil { + try? LocalAPISecretStore.save(secret: token, reference: LocalAPISecretStore.reference) + } + + return LocalAPIConfig( + isEnabled: persisted.isEnabled, + port: sanitizedPort(persisted.port), + token: token, + requestLoggingEnabled: persisted.requestLoggingEnabled + ) + } + + private static func saveConfig(_ config: LocalAPIConfig, defaults: UserDefaults = .standard) { + let persisted = PersistedLocalAPIConfig( + isEnabled: config.isEnabled, + port: sanitizedPort(config.port), + requestLoggingEnabled: config.requestLoggingEnabled + ) + + if let data = try? JSONEncoder().encode(persisted) { + defaults.set(data, forKey: configDefaultsKey) + } + + try? LocalAPISecretStore.save(secret: config.token, reference: LocalAPISecretStore.reference) + } + + private static func loadRequestLogs(defaults: UserDefaults = .standard) -> [APIRequestLog] { + guard let data = defaults.data(forKey: logsDefaultsKey), + let decoded = try? JSONDecoder().decode([APIRequestLog].self, from: data) else { + return [] + } + return decoded + } + + private static func saveRequestLogs(_ logs: [APIRequestLog], defaults: UserDefaults = .standard) { + if let data = try? JSONEncoder().encode(Array(logs.prefix(maxRequestLogs))) { + defaults.set(data, forKey: logsDefaultsKey) + } + } + + private static func sanitizedPort(_ port: Int) -> Int { + min(max(port, 1024), 65535) + } + + private static func generateToken() -> String { + let bytes = (0..<24).map { _ in UInt8.random(in: .min ... .max) } + let data = Data(bytes) + return data + .base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + private struct PersistedLocalAPIConfig: Codable { + var isEnabled: Bool = false + var port: Int = 47821 + var requestLoggingEnabled: Bool = true + } + + private enum LocalAPISecretStore { + static let reference = "DomainDig.LocalAPI.Token" + + static func save(secret: String, reference: String) throws { + let data = Data(secret.utf8) + try? delete(reference: reference) + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: reference, + kSecValueData as String: data, + kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlock + ] + + let status = SecItemAdd(query as CFDictionary, nil) + guard status == errSecSuccess else { + throw LocalAPIServerError.secretPersistenceFailed + } + } + + static func secret(reference: String) throws -> String { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: reference, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, + let data = result as? Data, + let secret = String(data: data, encoding: .utf8) else { + throw LocalAPIServerError.missingSecret + } + + return secret + } + + static func delete(reference: String) throws { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrAccount as String: reference + ] + SecItemDelete(query as CFDictionary) + } + } + +} + +private final class LocalAPIServer: @unchecked Sendable { + private let port: Int + private let token: String + private let requestLogger: @Sendable (APIRequestLog) -> Void + private let stateLogger: @Sendable (String) -> Void + private let handler = LocalAPIRequestHandler() + private let queue = DispatchQueue(label: "DomainDig.LocalAPIServer") + + private var listener: NWListener? + + init( + port: Int, + token: String, + requestLogger: @escaping @Sendable (APIRequestLog) -> Void, + stateLogger: @escaping @Sendable (String) -> Void + ) { + self.port = port + self.token = token + self.requestLogger = requestLogger + self.stateLogger = stateLogger + } + + func start() async throws -> Int { + let parameters = NWParameters.tcp + parameters.acceptLocalOnly = true + parameters.allowLocalEndpointReuse = true + parameters.requiredLocalEndpoint = .hostPort( + host: .ipv4(IPv4Address.loopback), + port: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any + ) + + let listener: NWListener + do { + listener = try NWListener( + using: parameters, + on: NWEndpoint.Port(rawValue: UInt16(port)) ?? .any + ) + } catch { + throw LocalAPIServerError.serverStartFailed("Could not start Local API on port \(port).") + } + + listener.newConnectionHandler = { [weak self] connection in + Task { + await self?.handle(connection: connection) + } + } + + return try await withCheckedThrowingContinuation { continuation in + let resumeState = ListenerResumeState() + + listener.stateUpdateHandler = { state in + switch state { + case .ready: + guard resumeState.beginResume() else { return } + continuation.resume(returning: Int(listener.port?.rawValue ?? UInt16(self.port))) + case .failed(let error): + self.stateLogger("Failed: \(error.localizedDescription)") + guard resumeState.beginResume() else { return } + continuation.resume(throwing: LocalAPIServerError.serverStartFailed(error.localizedDescription)) + case .cancelled: + self.stateLogger("Stopped") + default: + break + } + } + + self.listener = listener + listener.start(queue: self.queue) + } + } + + func stop() { + listener?.cancel() + listener = nil + } + + private func handle(connection: NWConnection) async { + connection.start(queue: queue) + let startedAt = Date() + + do { + let requestData = try await readCompleteRequest(from: connection) + let request = try LocalAPIHTTPParser.parseRequest(from: requestData) + let response = await handler.handle(request: request, expectedToken: token) + let log = APIRequestLog( + timestamp: startedAt, + method: request.method, + path: request.pathWithQuery, + statusCode: response.statusCode, + duration: Date().timeIntervalSince(startedAt) + ) + requestLogger(log) + try await send(response.serialized(), on: connection) + } catch let error as LocalAPIHTTPParser.ParserError { + let response = LocalAPIHTTPResponse.error(statusCode: 400, code: "bad_request", message: error.localizedDescription) + let log = APIRequestLog( + timestamp: startedAt, + method: "INVALID", + path: "/", + statusCode: response.statusCode, + duration: Date().timeIntervalSince(startedAt) + ) + requestLogger(log) + try? await send(response.serialized(), on: connection) + } catch { + let response = LocalAPIHTTPResponse.error(statusCode: 500, code: "internal_error", message: "The Local API request failed.") + let log = APIRequestLog( + timestamp: startedAt, + method: "ERROR", + path: "/", + statusCode: response.statusCode, + duration: Date().timeIntervalSince(startedAt) + ) + requestLogger(log) + try? await send(response.serialized(), on: connection) + } + + connection.cancel() + } + + private func readCompleteRequest(from connection: NWConnection, accumulated: Data = Data()) async throws -> Data { + let chunk = try await receiveChunk(from: connection) + let combined = accumulated + chunk.data + + if LocalAPIHTTPParser.isCompleteRequest(combined) || chunk.isComplete { + return combined + } + + return try await readCompleteRequest(from: connection, accumulated: combined) + } + + private func receiveChunk(from connection: NWConnection) async throws -> (data: Data, isComplete: Bool) { + try await withCheckedThrowingContinuation { continuation in + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in + if let error { + continuation.resume(throwing: error) + return + } + + continuation.resume(returning: (data ?? Data(), isComplete)) + } + } + } + + private func send(_ data: Data, on connection: NWConnection) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + connection.send(content: data, completion: .contentProcessed { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume(returning: ()) + } + }) + } + } +} + +private struct LocalAPIRequestHandler { + private let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = [.sortedKeys] + return encoder + }() + + private let decoder: JSONDecoder = { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + }() + + private let inspectionService = DomainInspectionService() + private let reportBuilder = DomainReportBuilder() + + func handle(request: LocalAPIHTTPRequest, expectedToken: String) async -> LocalAPIHTTPResponse { + guard isAuthorized(request: request, expectedToken: expectedToken) else { + return .error(statusCode: 401, code: "unauthorized", message: "A valid local API token is required.") + } + + if request.method == "GET", request.pathComponents == ["portfolio"] { + return successResponse(PortfolioPayload(summary: buildPortfolioSummary())) + } + + if request.method == "GET", request.pathComponents == ["domains"] { + return successResponse(DomainListPayload(domains: DomainDataPortabilityService.loadTrackedDomains())) + } + + if request.method == "GET", + request.pathComponents.count == 2, + request.pathComponents.first == "domains" { + let domain = request.pathComponents[1] + return domainDetailsResponse(for: domain) + } + + if request.method == "GET", + request.pathComponents.count == 3, + request.pathComponents.first == "domains", + request.pathComponents.last == "history" { + let domain = request.pathComponents[1] + return successResponse(DomainHistoryPayload(domain: normalizedDomain(domain), history: historyEntries(for: domain))) + } + + if request.method == "GET", request.pathComponents == ["events"] { + return successResponse(RecentEventsPayload(events: recentEvents())) + } + + if request.method == "GET", request.pathComponents == ["monitoring"] { + return successResponse(monitoringPayload()) + } + + if request.method == "POST", request.pathComponents == ["inspect"] { + return await inspectBodyResponse(request.body) + } + + if request.method == "POST", + request.pathComponents.count == 2, + request.pathComponents.first == "inspect" { + let domain = request.pathComponents[1] + return await inspectResponse(for: domain) + } + + if request.method == "POST", + request.pathComponents.count == 3, + request.pathComponents.first == "monitoring", + request.pathComponents.last == "enable" { + let domain = request.pathComponents[1] + return await setMonitoring(enabled: true, for: domain) + } + + if request.method == "POST", + request.pathComponents.count == 3, + request.pathComponents.first == "monitoring", + request.pathComponents.last == "disable" { + let domain = request.pathComponents[1] + return await setMonitoring(enabled: false, for: domain) + } + + return .error(statusCode: 404, code: "not_found", message: "The requested Local API route does not exist.") + } + + private func isAuthorized(request: LocalAPIHTTPRequest, expectedToken: String) -> Bool { + guard !expectedToken.isEmpty else { return false } + + if request.headers["x-api-token"] == expectedToken { + return true + } + + if let authorization = request.headers["authorization"], + authorization == "Bearer \(expectedToken)" { + return true + } + + return false + } + + private func successResponse(_ value: Value) -> LocalAPIHTTPResponse { + let envelope = LocalAPIEnvelope(success: true, data: value, error: nil, version: localAPIVersion) + guard let body = try? encoder.encode(envelope) else { + return .error(statusCode: 500, code: "encoding_failed", message: "Could not encode the Local API response.") + } + + return LocalAPIHTTPResponse(statusCode: 200, body: body) + } + + private func domainDetailsResponse(for domain: String) -> LocalAPIHTTPResponse { + let normalized = normalizedDomain(domain) + let trackedDomain = DomainDataPortabilityService.loadTrackedDomains().first { + $0.domain.caseInsensitiveCompare(normalized) == .orderedSame + } + let history = historyEntries(for: normalized) + let latestEntry = history.first + + guard trackedDomain != nil || latestEntry != nil else { + return .error(statusCode: 404, code: "domain_not_found", message: "No local data exists for \(normalized).") + } + + let latestReport = latestEntry.map { buildReport(for: $0, from: history) } + return successResponse( + DomainDetailPayload( + domain: normalized, + trackedDomain: trackedDomain, + latestReport: latestReport + ) + ) + } + + private func inspectBodyResponse(_ body: Data) async -> LocalAPIHTTPResponse { + guard let payload = try? decoder.decode(InspectRequestPayload.self, from: body) else { + return .error(statusCode: 400, code: "invalid_body", message: "Expected JSON body: {\"domain\":\"example.com\"}.") + } + + return await inspectResponse(for: payload.domain) + } + + private func inspectResponse(for domain: String) async -> LocalAPIHTTPResponse { + let normalized = normalizedDomain(domain) + guard !normalized.isEmpty else { + return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.") + } + + let previousSnapshot = latestHistoryEntry(for: normalized)?.snapshot + let report = await inspectionService.inspect(domain: normalized, previousSnapshot: previousSnapshot) + return successResponse(InspectResponsePayload(report: report)) + } + + private func setMonitoring(enabled: Bool, for domain: String) async -> LocalAPIHTTPResponse { + let normalized = normalizedDomain(domain) + guard !normalized.isEmpty else { + return .error(statusCode: 400, code: "invalid_domain", message: "A valid domain is required.") + } + + var trackedDomains = DomainDataPortabilityService.loadTrackedDomains() + guard let index = trackedDomains.firstIndex(where: { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame }) else { + return .error(statusCode: 404, code: "domain_not_found", message: "Tracked domain \(normalized) was not found.") + } + + trackedDomains[index].monitoringEnabled = enabled + trackedDomains[index].updatedAt = Date() + DomainDataPortabilityService.saveTrackedDomains(trackedDomains) + + let sanitizedSettings = MonitoringStorage.sanitizeSettings( + MonitoringStorage.loadSettings(), + trackedDomains: trackedDomains + ) + MonitoringStorage.saveSettings(sanitizedSettings) + + await MainActor.run { + _ = DomainMonitoringScheduler.shared.syncSchedule() + } + + return successResponse( + MonitoringMutationPayload( + domain: trackedDomains[index].domain, + monitoringEnabled: trackedDomains[index].monitoringEnabled + ) + ) + } + + private func buildPortfolioSummary() -> PortfolioSummary { + let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() + let history = DomainDataPortabilityService.loadHistoryEntries() + + var healthyCount = 0 + var warningCount = 0 + var criticalCount = 0 + var changedLast24h = 0 + var unreachableCount = 0 + + for trackedDomain in trackedDomains { + guard let latestEntry = latestHistoryEntry(for: trackedDomain.domain, history: history) else { continue } + let report = buildReport(for: latestEntry, from: history) + + switch report.health { + case .healthy: + healthyCount += 1 + case .warning: + warningCount += 1 + case .critical: + criticalCount += 1 + } + + if latestEntry.changeSummary?.hasChanges == true, + Date().timeIntervalSince(latestEntry.timestamp) <= 24 * 60 * 60 { + changedLast24h += 1 + } + + if report.network.reachabilityError != nil || report.lastMonitoringFailure != nil { + unreachableCount += 1 + } + } + + return PortfolioSummary( + totalDomains: trackedDomains.count, + healthyCount: healthyCount, + warningCount: warningCount, + criticalCount: criticalCount, + changedLast24h: changedLast24h, + expiringSoonCount: trackedDomains.filter { $0.certificateWarningLevel != .none }.count, + unreachableCount: unreachableCount + ) + } + + private func monitoringPayload() -> MonitoringPayload { + let trackedDomains = DomainDataPortabilityService.loadTrackedDomains() + let settings = MonitoringStorage.sanitizeSettings(MonitoringStorage.loadSettings(), trackedDomains: trackedDomains) + + return MonitoringPayload( + isEnabled: settings.isEnabled, + scope: settings.scope, + alertsEnabled: settings.alertsEnabled, + monitoredDomains: trackedDomains.map { + MonitoringDomainPayload( + domain: $0.domain, + monitoringEnabled: $0.monitoringEnabled, + lastMonitoredAt: $0.lastMonitoredAt, + lastAlertAt: $0.lastAlertAt, + certificateWarningLevel: $0.certificateWarningLevel + ) + } + ) + } + + private func recentEvents() -> [RecentEventPayload] { + MonitoringStorage.loadLogs() + .prefix(25) + .flatMap { log in + log.checkedDomains.map { result in + RecentEventPayload( + timestamp: result.checkedAt, + domain: result.domain, + summary: result.summaryMessage, + status: result.errorMessage == nil ? "ok" : "error", + severity: result.alertSeverity?.title ?? "Info" + ) + } + } + } + + private func historyEntries(for domain: String) -> [HistoryEntry] { + let normalized = normalizedDomain(domain) + return DomainDataPortabilityService.loadHistoryEntries().filter { + $0.domain.caseInsensitiveCompare(normalized) == .orderedSame + } + } + + private func latestHistoryEntry(for domain: String, history: [HistoryEntry]? = nil) -> HistoryEntry? { + let entries = history ?? DomainDataPortabilityService.loadHistoryEntries() + let normalized = normalizedDomain(domain) + return entries.first { $0.domain.caseInsensitiveCompare(normalized) == .orderedSame } + } + + private func buildReport(for entry: HistoryEntry, from history: [HistoryEntry]) -> DomainReport { + let previousSnapshot = history + .dropFirst(history.firstIndex(where: { $0.id == entry.id }).map { $0 + 1 } ?? history.count) + .first { $0.domain.caseInsensitiveCompare(entry.domain) == .orderedSame }? + .snapshot + return reportBuilder.build(from: entry, previousSnapshot: previousSnapshot) + } + + private func normalizedDomain(_ value: String) -> String { + value + .trimmingCharacters(in: .whitespacesAndNewlines) + .replacingOccurrences(of: "https://", with: "") + .replacingOccurrences(of: "http://", with: "") + .components(separatedBy: "/") + .first? + .lowercased() ?? "" + } +} + +private struct LocalAPIHTTPRequest { + let method: String + let path: String + let queryItems: [URLQueryItem] + let headers: [String: String] + let body: Data + + var pathComponents: [String] { + path.split(separator: "/").map(String.init) + } + + var pathWithQuery: String { + guard !queryItems.isEmpty else { return path } + let query = queryItems.compactMap { item in + guard let value = item.value else { return item.name } + return "\(item.name)=\(value)" + } + .joined(separator: "&") + return "\(path)?\(query)" + } +} + +private struct LocalAPIHTTPResponse { + let statusCode: Int + let body: Data + + init(statusCode: Int, body: Data) { + self.statusCode = statusCode + self.body = body + } + + static func error(statusCode: Int, code: String, message: String) -> LocalAPIHTTPResponse { + let payload = LocalAPIEnvelope( + success: false, + data: nil, + error: LocalAPIErrorPayload(code: code, message: message), + version: localAPIVersion + ) + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let body = (try? encoder.encode(payload)) ?? Data() + return LocalAPIHTTPResponse(statusCode: statusCode, body: body) + } + + func serialized() -> Data { + var response = Data() + response.append("HTTP/1.1 \(statusCode) \(Self.reasonPhrase(for: statusCode))\r\n".data(using: .utf8) ?? Data()) + response.append("Content-Type: application/json\r\n".data(using: .utf8) ?? Data()) + response.append("Content-Length: \(body.count)\r\n".data(using: .utf8) ?? Data()) + response.append("Connection: close\r\n\r\n".data(using: .utf8) ?? Data()) + response.append(body) + return response + } + + private static func reasonPhrase(for statusCode: Int) -> String { + switch statusCode { + case 200: + return "OK" + case 400: + return "Bad Request" + case 401: + return "Unauthorized" + case 404: + return "Not Found" + default: + return "Internal Server Error" + } + } +} + +private enum LocalAPIHTTPParser { + enum ParserError: LocalizedError { + case invalidRequestLine + case invalidPath + + var errorDescription: String? { + switch self { + case .invalidRequestLine: + return "The HTTP request line is invalid." + case .invalidPath: + return "The HTTP request path is invalid." + } + } + } + + static func isCompleteRequest(_ data: Data) -> Bool { + guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else { + return false + } + + let headerData = data[..= contentLength + } + + static func parseRequest(from data: Data) throws -> LocalAPIHTTPRequest { + guard let separatorRange = data.range(of: Data("\r\n\r\n".utf8)) else { + throw ParserError.invalidRequestLine + } + + let headerData = data[..= 2 else { + throw ParserError.invalidRequestLine + } + + let method = String(requestParts[0]).uppercased() + let rawPath = String(requestParts[1]) + guard let components = URLComponents(string: rawPath) else { + throw ParserError.invalidPath + } + + var headers: [String: String] = [:] + for line in lines.dropFirst() { + let parts = line.split(separator: ":", maxSplits: 1) + guard parts.count == 2 else { continue } + headers[String(parts[0]).lowercased()] = String(parts[1]).trimmingCharacters(in: .whitespaces) + } + + return LocalAPIHTTPRequest( + method: method, + path: components.path, + queryItems: components.queryItems ?? [], + headers: headers, + body: Data(bodyData) + ) + } +} + +private struct LocalAPIEnvelope: Encodable { + let success: Bool + let data: DataPayload? + let error: LocalAPIErrorPayload? + let version: String +} + +private struct LocalAPIErrorPayload: Encodable { + let code: String + let message: String +} + +private struct EmptyPayload: Encodable {} + +private struct PortfolioPayload: Encodable { + let summary: PortfolioSummary +} + +private struct PortfolioSummary: Encodable { + let totalDomains: Int + let healthyCount: Int + let warningCount: Int + let criticalCount: Int + let changedLast24h: Int + let expiringSoonCount: Int + let unreachableCount: Int +} + +private struct DomainListPayload: Encodable { + let domains: [TrackedDomain] +} + +private struct DomainDetailPayload: Encodable { + let domain: String + let trackedDomain: TrackedDomain? + let latestReport: DomainReport? +} + +private struct DomainHistoryPayload: Encodable { + let domain: String + let history: [HistoryEntry] +} + +private struct RecentEventsPayload: Encodable { + let events: [RecentEventPayload] +} + +private struct RecentEventPayload: Encodable { + let timestamp: Date + let domain: String + let summary: String + let status: String + let severity: String +} + +private struct MonitoringPayload: Encodable { + let isEnabled: Bool + let scope: MonitoringScope + let alertsEnabled: Bool + let monitoredDomains: [MonitoringDomainPayload] +} + +private struct MonitoringDomainPayload: Encodable { + let domain: String + let monitoringEnabled: Bool + let lastMonitoredAt: Date? + let lastAlertAt: Date? + let certificateWarningLevel: CertificateWarningLevel +} + +private struct MonitoringMutationPayload: Encodable { + let domain: String + let monitoringEnabled: Bool +} + +private struct InspectRequestPayload: Decodable { + let domain: String +} + +private struct InspectResponsePayload: Encodable { + let report: DomainReport +} -- cgit v1.2.3