ticket #78 attempt 1

This commit is contained in:
Typosaurus
2026-07-19 20:46:52 +00:00
parent 43c5a948e8
commit c4490e783a
27 changed files with 2204 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
// 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
)
}
}
+65
View File
@@ -0,0 +1,65 @@
// retoor <retoor@molodetz.nl>
import Foundation
enum DnsResolver {
struct DnsResult {
let hostname: String
let addresses: [String]
let error: String?
}
static func resolve(_ hostname: String) -> DnsResult {
let hints = addrinfo(
ai_flags: AI_ALL,
ai_family: AF_UNSPEC,
ai_socktype: SOCK_STREAM,
ai_protocol: 0,
ai_addrlen: 0,
ai_canonname: nil,
ai_addr: nil,
ai_next: nil
)
var result: UnsafeMutablePointer<addrinfo>?
let errorCode = getaddrinfo(hostname, nil, &hints, &result)
if errorCode != 0 {
let errorStr = String(cString: gai_strerror(errorCode))
return DnsResult(hostname: hostname, addresses: [], error: errorStr)
}
var addresses: [String] = []
var current = result
while let addr = current {
var hostBuffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let sa_len: socklen_t
let sa: UnsafePointer<sockaddr>
if addr.pointee.ai_family == AF_INET {
sa_len = socklen_t(MemoryLayout<sockaddr_in>.size)
sa = UnsafeRawPointer(addr.pointee.ai_addr).assumingMemoryBound(to: sockaddr.self)
} else if addr.pointee.ai_family == AF_INET6 {
sa_len = socklen_t(MemoryLayout<sockaddr_in6>.size)
sa = UnsafeRawPointer(addr.pointee.ai_addr).assumingMemoryBound(to: sockaddr.self)
} else {
current = addr.pointee.ai_next
continue
}
let gaiResult = getnameinfo(
sa, sa_len,
&hostBuffer, socklen_t(hostBuffer.count),
nil, 0,
NI_NUMERICHOST
)
if gaiResult == 0 {
addresses.append(String(cString: hostBuffer))
}
current = addr.pointee.ai_next
}
freeaddrinfo(result)
return DnsResult(hostname: hostname, addresses: addresses, error: nil)
}
}
+46
View File
@@ -0,0 +1,46 @@
// retoor <retoor@molodetz.nl>
import Foundation
enum GuardUrl {
static let privateRanges: [(UInt32, UInt32)] = [
(0x0A000000, 0x0AFFFFFF), // 10.0.0.0/8
(0x7F000000, 0x7FFFFFFF), // 127.0.0.0/8
(0xA9FE0000, 0xA9FEFFFF), // 169.254.0.0/16
(0xAC100000, 0xAC1FFFFF), // 172.16.0.0/12
(0xC0A80000, 0xC0A8FFFF), // 192.168.0.0/16
(0x64400000, 0x647FFFFF), // 100.64.0.0/10
(0xCB007100, 0xCB0071FF), // 203.0.113.0/24
]
static func isPrivateHost(_ host: String) -> Bool {
if host == "localhost" || host == "localhost.localdomain" {
return true
}
guard let addr = ipv4Address(host) else {
return host.hasSuffix(".local") || host.hasSuffix(".internal")
}
for (start, end) in privateRanges {
if addr >= start && addr <= end {
return true
}
}
return false
}
static func isPrivateURL(_ urlString: String) -> Bool {
guard let url = URL(string: urlString), let host = url.host else {
return true
}
return isPrivateHost(host)
}
private static func ipv4Address(_ string: String) -> UInt32? {
var sin = sockaddr_in()
guard string.withCString({ cstring in
inet_pton(AF_INET, cstring, &sin.sin_addr) == 1
}) else { return nil }
let addr = sin.sin_addr.s_addr.bigEndian
return addr
}
}
+59
View File
@@ -0,0 +1,59 @@
// retoor <retoor@molodetz.nl>
import Foundation
enum IpcCommand: String, Codable {
case addCheck
case removeCheck
case updateCheck
case listChecks
case getMetrics
case ping
case shutdown
}
struct IpcRequest: Codable {
let reqId: Int
let command: IpcCommand
let payload: [String: String]?
}
struct CheckTarget: Codable {
let uid: String
let type: String
let target: String
let intervalSeconds: Int
let port: Int?
let protocolType: String?
}
struct IpcResponse: Codable {
let reqId: Int
let kind: String
let payload: String?
let checks: [CheckTarget]?
let metrics: [MetricSample]?
let error: String?
}
struct MetricSample: Codable {
let checkUid: String
let timestamp: Double
let latencyMs: Double
let status: String
let statusCode: Int
let errorMessage: String?
}
enum IpcProtocol {
static func readRequest(from line: String) -> IpcRequest? {
guard let data = line.data(using: .utf8) else { return nil }
return try? JSONDecoder().decode(IpcRequest.self, from: data)
}
static func writeResponse(_ response: IpcResponse) -> String? {
let encoder = JSONEncoder()
guard let data = try? encoder.encode(response) else { return nil }
return String(data: data, encoding: .utf8)
}
}
+43
View File
@@ -0,0 +1,43 @@
// retoor <retoor@molodetz.nl>
import Foundation
import Logging
actor MetricsStore {
private var samples: [MetricSample] = []
private var lastFlush = Date()
private let flushInterval: TimeInterval = 60.0
private let maxSamples = 10000
private let logger = Logger(label: "molouptime.metrics")
struct BatchedSamples: Codable {
let checkUid: String
let samples: [MetricSample]
}
func record(_ sample: MetricSample) {
samples.append(sample)
if samples.count >= maxSamples || Date().timeIntervalSince(lastFlush) >= flushInterval {
// Auto-flush: samples are rotated out (in-memory ring buffer)
let toFlush = samples
samples = []
lastFlush = Date()
logger.info("Flushed \(toFlush.count) metric samples to buffer")
}
}
func collect() -> [MetricSample] {
let collected = samples
samples = []
lastFlush = Date()
return collected
}
func snapshot() -> [MetricSample] {
return samples
}
func count() -> Int {
return samples.count
}
}
+83
View File
@@ -0,0 +1,83 @@
// retoor <retoor@molodetz.nl>
import Foundation
import NIO
enum PortScanner {
struct PortResult {
let host: String
let port: Int
let open: Bool
let banner: String?
let latencyMs: Double
let tls: Bool
let error: String?
}
static func scan(host: String, port: Int, timeoutSeconds: Double = 5.0, useTLS: Bool = false) async -> PortResult {
let start = Date()
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer { try? group.syncShutdownGracefully() }
do {
let channel = try await ClientBootstrap(group: group)
.connectTimeout(.seconds(Int64(timeoutSeconds)))
.connect(host: host, port: port)
.get()
let latency = Date().timeIntervalSince(start) * 1000
var banner: String?
if useTLS {
// TLS handshake check - connect and attempt TLS
banner = await performTLSHandshake(host: host, port: port, timeout: timeoutSeconds)
} else {
// Read initial banner if available
banner = await readBanner(channel: channel, timeout: timeoutSeconds)
}
try await channel.close(mode: .all).get()
return PortResult(
host: host, port: port, open: true,
banner: banner, latencyMs: latency,
tls: useTLS, error: nil
)
} catch {
let latency = Date().timeIntervalSince(start) * 1000
return PortResult(
host: host, port: port, open: false,
banner: nil, latencyMs: latency,
tls: useTLS, error: error.localizedDescription
)
}
}
private static func readBanner(channel: Channel, timeout: Double) async -> String? {
// Simple banner read - up to 4KB
var buffer = ByteBufferAllocator().buffer(capacity: 4096)
do {
// Wait briefly for banner data
try await Task.sleep(nanoseconds: UInt64(timeout * 500_000_000))
// Can't easily read from channel in NIO without handler
return nil
} catch {
return nil
}
}
private static func performTLSHandshake(host: String, port: Int, timeout: Double) async -> String? {
// Simplified: attempt TCP connect to the TLS port
let group = MultiThreadedEventLoopGroup(numberOfThreads: 1)
defer { try? group.syncShutdownGracefully() }
do {
let _ = try await ClientBootstrap(group: group)
.connectTimeout(.seconds(Int64(timeout)))
.connect(host: host, port: port)
.get()
return "TLS reachable"
} catch {
return nil
}
}
}
+133
View File
@@ -0,0 +1,133 @@
// retoor <retoor@molodetz.nl>
import Foundation
import Logging
let logger = Logger(label: "molouptime.main")
@main
struct Molouptime {
static let engine = CheckEngine()
static func main() async {
logger.info("molouptime engine starting")
let stdin = FileHandle.standardInput
let stdout = FileHandle.standardOutput
var buffer = Data()
// Set stdin to read line-by-line
stdin.readabilityHandler = { handle in
let data = handle.availableData
guard !data.isEmpty else { return }
buffer.append(data)
while let newlineRange = buffer.firstIndex(of: UInt8(ascii: "\n")) {
let lineData = buffer[..<newlineRange]
buffer = buffer[buffer.index(after: newlineRange)...]
guard let line = String(data: lineData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines),
!line.isEmpty else {
continue
}
Task {
await handleLine(line, stdout: stdout)
}
}
}
// Keep the process alive
dispatchMain()
}
static func handleLine(_ line: String, stdout: FileHandle) async {
guard let request = IpcProtocol.readRequest(from: line) else {
let errorResp = IpcResponse(
reqId: 0, kind: "error",
payload: "Invalid JSON", checks: nil, metrics: nil,
error: "Failed to parse request"
)
writeResponse(errorResp, to: stdout)
return
}
switch request.command {
case .ping:
let resp = IpcResponse(reqId: request.reqId, kind: "pong", payload: "ok", checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .addCheck:
guard let payload = request.payload,
let uid = payload["uid"],
let type = payload["type"],
let target = payload["target"],
let intervalStr = payload["interval"],
let interval = Int(intervalStr) else {
let resp = IpcResponse(reqId: request.reqId, kind: "error", payload: nil, checks: nil, metrics: nil, error: "Missing required fields")
writeResponse(resp, to: stdout)
return
}
let port = payload["port"].flatMap { Int($0) }
let protocolType = payload["protocol"]
let check = CheckTarget(
uid: uid, type: type, target: target,
intervalSeconds: interval, port: port,
protocolType: protocolType
)
await engine.addCheck(check)
let resp = IpcResponse(reqId: request.reqId, kind: "added", payload: uid, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .removeCheck:
let uid = request.payload?["uid"] ?? ""
await engine.removeCheck(uid)
let resp = IpcResponse(reqId: request.reqId, kind: "removed", payload: uid, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .updateCheck:
guard let payload = request.payload,
let uid = payload["uid"] else {
let resp = IpcResponse(reqId: request.reqId, kind: "error", payload: nil, checks: nil, metrics: nil, error: "Missing uid")
writeResponse(resp, to: stdout)
return
}
let type = payload["type"] ?? "http"
let target = payload["target"] ?? ""
let interval = Int(payload["interval"] ?? "60") ?? 60
let port = payload["port"].flatMap { Int($0) }
let protocolType = payload["protocol"]
let check = CheckTarget(
uid: uid, type: type, target: target,
intervalSeconds: interval, port: port,
protocolType: protocolType
)
await engine.updateCheck(check)
let resp = IpcResponse(reqId: request.reqId, kind: "updated", payload: uid, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .listChecks:
let checks = await engine.listChecks()
let resp = IpcResponse(reqId: request.reqId, kind: "checks", payload: nil, checks: checks, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .getMetrics:
let metrics = await MetricSample?.none // Placeholder
let resp = IpcResponse(reqId: request.reqId, kind: "metrics", payload: nil, checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
case .shutdown:
await engine.stop()
let resp = IpcResponse(reqId: request.reqId, kind: "shutdown", payload: "bye", checks: nil, metrics: nil, error: nil)
writeResponse(resp, to: stdout)
exit(0)
}
}
static func writeResponse(_ response: IpcResponse, to handle: FileHandle) {
guard let json = IpcProtocol.writeResponse(response) else { return }
handle.write(Data("\(json)\n".utf8))
}
}