chore: rename project to clawdbot
This commit is contained in:
408
apps/macos/Sources/Clawdbot/PortGuardian.swift
Normal file
408
apps/macos/Sources/Clawdbot/PortGuardian.swift
Normal file
@@ -0,0 +1,408 @@
|
||||
import Foundation
|
||||
import OSLog
|
||||
#if canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
actor PortGuardian {
|
||||
static let shared = PortGuardian()
|
||||
|
||||
struct Record: Codable {
|
||||
let port: Int
|
||||
let pid: Int32
|
||||
let command: String
|
||||
let mode: String
|
||||
let timestamp: TimeInterval
|
||||
}
|
||||
|
||||
struct Descriptor: Sendable {
|
||||
let pid: Int32
|
||||
let command: String
|
||||
let executablePath: String?
|
||||
}
|
||||
|
||||
private var records: [Record] = []
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "portguard")
|
||||
private nonisolated static let appSupportDir: URL = {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first!
|
||||
return base.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
}()
|
||||
|
||||
private nonisolated static var recordPath: URL {
|
||||
self.appSupportDir.appendingPathComponent("port-guard.json", isDirectory: false)
|
||||
}
|
||||
|
||||
init() {
|
||||
self.records = Self.loadRecords(from: Self.recordPath)
|
||||
}
|
||||
|
||||
func sweep(mode: AppState.ConnectionMode) async {
|
||||
self.logger.info("port sweep starting (mode=\(mode.rawValue, privacy: .public))")
|
||||
guard mode != .unconfigured else {
|
||||
self.logger.info("port sweep skipped (mode=unconfigured)")
|
||||
return
|
||||
}
|
||||
let ports = [GatewayEnvironment.gatewayPort()]
|
||||
for port in ports {
|
||||
let listeners = await self.listeners(on: port)
|
||||
guard !listeners.isEmpty else { continue }
|
||||
for listener in listeners {
|
||||
if self.isExpected(listener, port: port, mode: mode) {
|
||||
let message = """
|
||||
port \(port) already served by expected \(listener.command)
|
||||
(pid \(listener.pid)) — keeping
|
||||
"""
|
||||
self.logger.info("\(message, privacy: .public)")
|
||||
continue
|
||||
}
|
||||
let killed = await self.kill(listener.pid)
|
||||
if killed {
|
||||
let message = """
|
||||
port \(port) was held by \(listener.command)
|
||||
(pid \(listener.pid)); terminated
|
||||
"""
|
||||
self.logger.error("\(message, privacy: .public)")
|
||||
} else {
|
||||
self.logger.error("failed to terminate pid \(listener.pid) on port \(port, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
self.logger.info("port sweep done")
|
||||
}
|
||||
|
||||
func record(port: Int, pid: Int32, command: String, mode: AppState.ConnectionMode) async {
|
||||
try? FileManager.default.createDirectory(at: Self.appSupportDir, withIntermediateDirectories: true)
|
||||
self.records.removeAll { $0.pid == pid }
|
||||
self.records.append(
|
||||
Record(
|
||||
port: port,
|
||||
pid: pid,
|
||||
command: command,
|
||||
mode: mode.rawValue,
|
||||
timestamp: Date().timeIntervalSince1970))
|
||||
self.save()
|
||||
}
|
||||
|
||||
func removeRecord(pid: Int32) {
|
||||
let before = self.records.count
|
||||
self.records.removeAll { $0.pid == pid }
|
||||
if self.records.count != before {
|
||||
self.save()
|
||||
}
|
||||
}
|
||||
|
||||
struct PortReport: Identifiable {
|
||||
enum Status {
|
||||
case ok(String)
|
||||
case missing(String)
|
||||
case interference(String, offenders: [ReportListener])
|
||||
}
|
||||
|
||||
let port: Int
|
||||
let expected: String
|
||||
let status: Status
|
||||
let listeners: [ReportListener]
|
||||
|
||||
var id: Int { self.port }
|
||||
|
||||
var offenders: [ReportListener] {
|
||||
if case let .interference(_, offenders) = self.status { return offenders }
|
||||
return []
|
||||
}
|
||||
|
||||
var summary: String {
|
||||
switch self.status {
|
||||
case let .ok(text): text
|
||||
case let .missing(text): text
|
||||
case let .interference(text, _): text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func describe(port: Int) async -> Descriptor? {
|
||||
guard let listener = await self.listeners(on: port).first else { return nil }
|
||||
let path = Self.executablePath(for: listener.pid)
|
||||
return Descriptor(pid: listener.pid, command: listener.command, executablePath: path)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private struct Listener {
|
||||
let pid: Int32
|
||||
let command: String
|
||||
let fullCommand: String
|
||||
let user: String?
|
||||
}
|
||||
|
||||
struct ReportListener: Identifiable {
|
||||
let pid: Int32
|
||||
let command: String
|
||||
let fullCommand: String
|
||||
let user: String?
|
||||
let expected: Bool
|
||||
|
||||
var id: Int32 { self.pid }
|
||||
}
|
||||
|
||||
func diagnose(mode: AppState.ConnectionMode) async -> [PortReport] {
|
||||
if mode == .unconfigured {
|
||||
return []
|
||||
}
|
||||
let ports = [GatewayEnvironment.gatewayPort()]
|
||||
var reports: [PortReport] = []
|
||||
|
||||
for port in ports {
|
||||
let listeners = await self.listeners(on: port)
|
||||
let tunnelHealthy = await self.probeGatewayHealthIfNeeded(
|
||||
port: port,
|
||||
mode: mode,
|
||||
listeners: listeners)
|
||||
reports.append(Self.buildReport(
|
||||
port: port,
|
||||
listeners: listeners,
|
||||
mode: mode,
|
||||
tunnelHealthy: tunnelHealthy))
|
||||
}
|
||||
|
||||
return reports
|
||||
}
|
||||
|
||||
func probeGatewayHealth(port: Int, timeout: TimeInterval = 2.0) async -> Bool {
|
||||
let url = URL(string: "http://127.0.0.1:\(port)/")!
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = timeout
|
||||
config.timeoutIntervalForResource = timeout
|
||||
let session = URLSession(configuration: config)
|
||||
var request = URLRequest(url: url)
|
||||
request.cachePolicy = .reloadIgnoringLocalCacheData
|
||||
request.timeoutInterval = timeout
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
return response is HTTPURLResponse
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func listeners(on port: Int) async -> [Listener] {
|
||||
let res = await ShellExecutor.run(
|
||||
command: ["lsof", "-nP", "-iTCP:\(port)", "-sTCP:LISTEN", "-Fpcn"],
|
||||
cwd: nil,
|
||||
env: nil,
|
||||
timeout: 5)
|
||||
guard res.ok, let data = res.payload, !data.isEmpty else { return [] }
|
||||
let text = String(data: data, encoding: .utf8) ?? ""
|
||||
return Self.parseListeners(from: text)
|
||||
}
|
||||
|
||||
private static func readFullCommand(pid: Int32) -> String? {
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: "/bin/ps")
|
||||
proc.arguments = ["-p", "\(pid)", "-o", "command="]
|
||||
let pipe = Pipe()
|
||||
proc.standardOutput = pipe
|
||||
proc.standardError = Pipe()
|
||||
do {
|
||||
try proc.run()
|
||||
proc.waitUntilExit()
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
let data = pipe.fileHandleForReading.readToEndSafely()
|
||||
guard !data.isEmpty else { return nil }
|
||||
return String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private static func parseListeners(from text: String) -> [Listener] {
|
||||
var listeners: [Listener] = []
|
||||
var currentPid: Int32?
|
||||
var currentCmd: String?
|
||||
var currentUser: String?
|
||||
|
||||
func flush() {
|
||||
if let pid = currentPid, let cmd = currentCmd {
|
||||
let full = Self.readFullCommand(pid: pid) ?? cmd
|
||||
listeners.append(Listener(pid: pid, command: cmd, fullCommand: full, user: currentUser))
|
||||
}
|
||||
currentPid = nil
|
||||
currentCmd = nil
|
||||
currentUser = nil
|
||||
}
|
||||
|
||||
for line in text.split(separator: "\n") {
|
||||
guard let prefix = line.first else { continue }
|
||||
let value = String(line.dropFirst())
|
||||
switch prefix {
|
||||
case "p":
|
||||
flush()
|
||||
currentPid = Int32(value) ?? 0
|
||||
case "c":
|
||||
currentCmd = value
|
||||
case "u":
|
||||
currentUser = value
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
flush()
|
||||
return listeners
|
||||
}
|
||||
|
||||
private static func buildReport(
|
||||
port: Int,
|
||||
listeners: [Listener],
|
||||
mode: AppState.ConnectionMode,
|
||||
tunnelHealthy: Bool?) -> PortReport
|
||||
{
|
||||
let expectedDesc: String
|
||||
let okPredicate: (Listener) -> Bool
|
||||
let expectedCommands = ["node", "clawdbot", "tsx", "pnpm", "bun"]
|
||||
|
||||
switch mode {
|
||||
case .remote:
|
||||
expectedDesc = "SSH tunnel to remote gateway"
|
||||
okPredicate = { $0.command.lowercased().contains("ssh") }
|
||||
case .local:
|
||||
expectedDesc = "Gateway websocket (node/tsx)"
|
||||
okPredicate = { listener in
|
||||
let c = listener.command.lowercased()
|
||||
return expectedCommands.contains { c.contains($0) }
|
||||
}
|
||||
case .unconfigured:
|
||||
expectedDesc = "Gateway not configured"
|
||||
okPredicate = { _ in false }
|
||||
}
|
||||
|
||||
if listeners.isEmpty {
|
||||
let text = "Nothing is listening on \(port) (\(expectedDesc))."
|
||||
return .init(port: port, expected: expectedDesc, status: .missing(text), listeners: [])
|
||||
}
|
||||
|
||||
let tunnelUnhealthy =
|
||||
mode == .remote && port == GatewayEnvironment.gatewayPort() && tunnelHealthy == false
|
||||
let reportListeners = listeners.map { listener in
|
||||
var expected = okPredicate(listener)
|
||||
if tunnelUnhealthy, expected { expected = false }
|
||||
return ReportListener(
|
||||
pid: listener.pid,
|
||||
command: listener.command,
|
||||
fullCommand: listener.fullCommand,
|
||||
user: listener.user,
|
||||
expected: expected)
|
||||
}
|
||||
|
||||
let offenders = reportListeners.filter { !$0.expected }
|
||||
if tunnelUnhealthy {
|
||||
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let reason = "Port \(port) is served by \(list), but the SSH tunnel is unhealthy."
|
||||
return .init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .interference(reason, offenders: offenders),
|
||||
listeners: reportListeners)
|
||||
}
|
||||
if offenders.isEmpty {
|
||||
let list = listeners.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let okText = "Port \(port) is served by \(list)."
|
||||
return .init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .ok(okText),
|
||||
listeners: reportListeners)
|
||||
}
|
||||
|
||||
let list = offenders.map { "\($0.command) (\($0.pid))" }.joined(separator: ", ")
|
||||
let reason = "Port \(port) is held by \(list), expected \(expectedDesc)."
|
||||
return .init(
|
||||
port: port,
|
||||
expected: expectedDesc,
|
||||
status: .interference(reason, offenders: offenders),
|
||||
listeners: reportListeners)
|
||||
}
|
||||
|
||||
private static func executablePath(for pid: Int32) -> String? {
|
||||
#if canImport(Darwin)
|
||||
var buffer = [CChar](repeating: 0, count: Int(PATH_MAX))
|
||||
let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
|
||||
guard length > 0 else { return nil }
|
||||
// Drop trailing null and decode as UTF-8.
|
||||
let trimmed = buffer.prefix { $0 != 0 }
|
||||
let bytes = trimmed.map { UInt8(bitPattern: $0) }
|
||||
return String(bytes: bytes, encoding: .utf8)
|
||||
#else
|
||||
return nil
|
||||
#endif
|
||||
}
|
||||
|
||||
private func kill(_ pid: Int32) async -> Bool {
|
||||
let term = await ShellExecutor.run(command: ["kill", "-TERM", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
if term.ok { return true }
|
||||
let sigkill = await ShellExecutor.run(command: ["kill", "-KILL", "\(pid)"], cwd: nil, env: nil, timeout: 2)
|
||||
return sigkill.ok
|
||||
}
|
||||
|
||||
private func isExpected(_ listener: Listener, port: Int, mode: AppState.ConnectionMode) -> Bool {
|
||||
let cmd = listener.command.lowercased()
|
||||
let expectedCommands = ["node", "clawdbot", "tsx", "pnpm", "bun"]
|
||||
switch mode {
|
||||
case .remote:
|
||||
// Remote mode expects an SSH tunnel for the gateway WebSocket port.
|
||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||
return false
|
||||
case .local:
|
||||
return expectedCommands.contains { cmd.contains($0) }
|
||||
case .unconfigured:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private func probeGatewayHealthIfNeeded(
|
||||
port: Int,
|
||||
mode: AppState.ConnectionMode,
|
||||
listeners: [Listener]) async -> Bool?
|
||||
{
|
||||
guard mode == .remote, port == GatewayEnvironment.gatewayPort(), !listeners.isEmpty else { return nil }
|
||||
let hasSsh = listeners.contains { $0.command.lowercased().contains("ssh") }
|
||||
guard hasSsh else { return nil }
|
||||
return await self.probeGatewayHealth(port: port)
|
||||
}
|
||||
|
||||
private static func loadRecords(from url: URL) -> [Record] {
|
||||
guard let data = try? Data(contentsOf: url),
|
||||
let decoded = try? JSONDecoder().decode([Record].self, from: data)
|
||||
else { return [] }
|
||||
return decoded
|
||||
}
|
||||
|
||||
private func save() {
|
||||
guard let data = try? JSONEncoder().encode(self.records) else { return }
|
||||
try? data.write(to: Self.recordPath, options: [.atomic])
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension PortGuardian {
|
||||
static func _testParseListeners(_ text: String) -> [(
|
||||
pid: Int32,
|
||||
command: String,
|
||||
fullCommand: String,
|
||||
user: String?)]
|
||||
{
|
||||
self.parseListeners(from: text).map { ($0.pid, $0.command, $0.fullCommand, $0.user) }
|
||||
}
|
||||
|
||||
static func _testBuildReport(
|
||||
port: Int,
|
||||
mode: AppState.ConnectionMode,
|
||||
listeners: [(pid: Int32, command: String, fullCommand: String, user: String?)]) -> PortReport
|
||||
{
|
||||
let mapped = listeners.map { Listener(
|
||||
pid: $0.pid,
|
||||
command: $0.command,
|
||||
fullCommand: $0.fullCommand,
|
||||
user: $0.user) }
|
||||
return Self.buildReport(port: port, listeners: mapped, mode: mode, tunnelHealthy: nil)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user