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