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