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