227 lines
7.2 KiB
Swift
227 lines
7.2 KiB
Swift
|
|
// retoor <retoor@molodetz.nl>
|
||
|
|
|
||
|
|
import Foundation
|
||
|
|
import AsyncHTTPClient
|
||
|
|
import NIOCore
|
||
|
|
import NIOPosix
|
||
|
|
import Logging
|
||
|
|
|
||
|
|
actor CheckEngine {
|
||
|
|
private var checks: [String: CheckTarget] = [:]
|
||
|
|
private var activeTasks: [String: Task<Void, Never>] = [:]
|
||
|
|
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
|
||
|
|
)
|
||
|
|
}
|
||
|
|
}
|