| @@ -0,0 +1,1029 @@ |
| |
1 | import Foundation |
| |
2 | import Network |
| |
3 | import Observation |
| |
4 | import Security |
| |
5 | |
| |
6 | private let localAPIVersion = "v1" |
| |
7 | |
| |
8 | private 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 | |
| |
25 | private 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 |
| |
41 | final 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 | |
| |
347 | private 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 | |
| |
504 | private 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 | |
| |
797 | private 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 | |
| |
819 | private 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 | |
| |
868 | private 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 | |
| |
948 | private struct LocalAPIEnvelope<DataPayload: Encodable>: Encodable { |
| |
949 | let success: Bool |
| |
950 | let data: DataPayload? |
| |
951 | let error: LocalAPIErrorPayload? |
| |
952 | let version: String |
| |
953 | } |
| |
954 | |
| |
955 | private struct LocalAPIErrorPayload: Encodable { |
| |
956 | let code: String |
| |
957 | let message: String |
| |
958 | } |
| |
959 | |
| |
960 | private struct EmptyPayload: Encodable {} |
| |
961 | |
| |
962 | private struct PortfolioPayload: Encodable { |
| |
963 | let summary: PortfolioSummary |
| |
964 | } |
| |
965 | |
| |
966 | private 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 | |
| |
976 | private struct DomainListPayload: Encodable { |
| |
977 | let domains: [TrackedDomain] |
| |
978 | } |
| |
979 | |
| |
980 | private struct DomainDetailPayload: Encodable { |
| |
981 | let domain: String |
| |
982 | let trackedDomain: TrackedDomain? |
| |
983 | let latestReport: DomainReport? |
| |
984 | } |
| |
985 | |
| |
986 | private struct DomainHistoryPayload: Encodable { |
| |
987 | let domain: String |
| |
988 | let history: [HistoryEntry] |
| |
989 | } |
| |
990 | |
| |
991 | private struct RecentEventsPayload: Encodable { |
| |
992 | let events: [RecentEventPayload] |
| |
993 | } |
| |
994 | |
| |
995 | private 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 | |
| |
1003 | private struct MonitoringPayload: Encodable { |
| |
1004 | let isEnabled: Bool |
| |
1005 | let scope: MonitoringScope |
| |
1006 | let alertsEnabled: Bool |
| |
1007 | let monitoredDomains: [MonitoringDomainPayload] |
| |
1008 | } |
| |
1009 | |
| |
1010 | private 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 | |
| |
1018 | private struct MonitoringMutationPayload: Encodable { |
| |
1019 | let domain: String |
| |
1020 | let monitoringEnabled: Bool |
| |
1021 | } |
| |
1022 | |
| |
1023 | private struct InspectRequestPayload: Decodable { |
| |
1024 | let domain: String |
| |
1025 | } |
| |
1026 | |
| |
1027 | private struct InspectResponsePayload: Encodable { |
| |
1028 | let report: DomainReport |
| |
1029 | } |