// retoor import Foundation import AsyncHTTPClient import NIOCore import NIOPosix import Logging actor CheckEngine { private var checks: [String: CheckTarget] = [:] private var activeTasks: [String: Task] = [:] private let metrics = MetricsStore() private let httpClient: HTTPClient private let logger = Logger(label: "molouptime.engine") enum CheckError: Error, LocalizedError { case engineStopped case privateTarget(String) case checkFailed(String) var errorDescription: String? { switch self { case .engineStopped: return "Engine is stopped" case .privateTarget(let t): return "Target \(t) is a private address" case .checkFailed(let m): return m } } } init() { let configuration = HTTPClient.Configuration( timeout: .init(connect: .seconds(10), read: .seconds(15)) ) self.httpClient = HTTPClient( eventLoopGroupProvider: .shared(MultiThreadedEventLoopGroup(numberOfThreads: 4)), configuration: configuration ) } deinit { try? httpClient.syncShutdown() } var onResult: ((MetricSample) -> Void)? var onStateChange: ((String, String, String) -> Void)? func addCheck(_ check: CheckTarget) { checks[check.uid] = check startCheck(check) logger.info("Added check \(check.uid) for \(check.target)") } func removeCheck(_ uid: String) { checks[uid] = nil activeTasks[uid]?.cancel() activeTasks[uid] = nil logger.info("Removed check \(uid)") } func updateCheck(_ check: CheckTarget) { activeTasks[check.uid]?.cancel() checks[check.uid] = check startCheck(check) logger.info("Updated check \(check.uid)") } func listChecks() -> [CheckTarget] { return Array(checks.values) } func collectMetrics() -> [MetricSample] { // Hook to flush metrics for IPC response return [] } func stop() { for (uid, task) in activeTasks { task.cancel() activeTasks[uid] = nil } checks.removeAll() try? httpClient.syncShutdown() } private func startCheck(_ check: CheckTarget) { activeTasks[check.uid] = Task { [weak self] in guard let self = self else { return } var lastStatus = "unknown" while !Task.isCancelled { let sample = await self.performCheck(check) let statusChanged = sample.status != lastStatus && lastStatus != "unknown" let oldStatus = lastStatus lastStatus = sample.status await self.metrics.record(sample) self.onResult?(sample) if statusChanged { self.onStateChange?(check.uid, oldStatus, sample.status) } do { try await Task.sleep(nanoseconds: UInt64(check.intervalSeconds) * 1_000_000_000) } catch { break } } } } private func performCheck(_ check: CheckTarget) async -> MetricSample { let start = Date() switch check.type { case "http": return await performHTTPCheck(check, start: start) case "dns": return await performDNSCheck(check, start: start) case "port": return await performPortCheck(check, start: start) default: return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: 0, status: "error", statusCode: 0, errorMessage: "Unknown check type: \(check.type)" ) } } private func performHTTPCheck(_ check: CheckTarget, start: Date) async -> MetricSample { guard !GuardUrl.isPrivateURL(check.target) else { return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: 0, status: "error", statusCode: 0, errorMessage: "Private target rejected: \(check.target)" ) } do { let request = try HTTPClient.Request(url: check.target, method: .GET) let response = try await httpClient.execute(request, deadline: .now() + .seconds(15)).get() let latency = Date().timeIntervalSince(start) * 1000 let status = response.status.code < 500 ? "up" : "down" return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: latency, status: status, statusCode: Int(response.status.code), errorMessage: nil ) } catch { let latency = Date().timeIntervalSince(start) * 1000 return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: latency, status: "down", statusCode: 0, errorMessage: error.localizedDescription ) } } private func performDNSCheck(_ check: CheckTarget, start: Date) async -> MetricSample { let host = URL(string: check.target)?.host ?? check.target let result = DnsResolver.resolve(host) let latency = Date().timeIntervalSince(start) * 1000 if let error = result.error { return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: latency, status: "down", statusCode: 0, errorMessage: error ) } return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: latency, status: result.addresses.isEmpty ? "down" : "up", statusCode: result.addresses.count, errorMessage: result.addresses.isEmpty ? "No addresses resolved" : nil ) } private func performPortCheck(_ check: CheckTarget, start: Date) async -> MetricSample { let host = URL(string: check.target)?.host ?? check.target let port = check.port ?? 80 let useTLS = check.protocolType == "tls" guard !GuardUrl.isPrivateHost(host) else { return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: 0, status: "error", statusCode: 0, errorMessage: "Private host rejected: \(host)" ) } let result = await PortScanner.scan(host: host, port: port, useTLS: useTLS) return MetricSample( checkUid: check.uid, timestamp: start.timeIntervalSince1970, latencyMs: result.latencyMs, status: result.open ? "up" : "down", statusCode: result.open ? 1 : 0, errorMessage: result.error ) } }