import Foundation import OpenClawKit struct TailscaleServeGatewayBeacon: Equatable { var displayName: String var tailnetDns: String var host: String var port: Int } enum TailscaleServeGatewayDiscovery { private static let maxCandidates = 32 private static let probeConcurrency = 6 private static let defaultProbeTimeoutSeconds: TimeInterval = 1.6 struct DiscoveryContext { var tailscaleStatus: @Sendable () async -> String? var probeHost: @Sendable (_ host: String, _ timeout: TimeInterval) async -> Bool static let live = DiscoveryContext( tailscaleStatus: { await readTailscaleStatus() }, probeHost: { host, timeout in await probeHostForGatewayChallenge(host: host, timeout: timeout) }) } static func discover( timeoutSeconds: TimeInterval = 3.0, context: DiscoveryContext = .live) async -> [TailscaleServeGatewayBeacon] { guard timeoutSeconds > 0 else { return [] } guard let statusJson = await context.tailscaleStatus(), let status = parseStatus(statusJson) else { return [] } let candidates = self.collectCandidates(status: status) if candidates.isEmpty { return [] } let deadline = Date().addingTimeInterval(timeoutSeconds) let perProbeTimeout = min(self.defaultProbeTimeoutSeconds, max(0.5, timeoutSeconds * 0.45)) var byHost: [String: TailscaleServeGatewayBeacon] = [:] await withTaskGroup(of: TailscaleServeGatewayBeacon?.self) { group in var index = 0 let workerCount = min(self.probeConcurrency, candidates.count) func submitOne() { guard index < candidates.count else { return } let candidate = candidates[index] index += 1 group.addTask { let remaining = deadline.timeIntervalSinceNow if remaining <= 0 { return nil } let timeout = min(perProbeTimeout, remaining) let reachable = await context.probeHost(candidate.dnsName, timeout) if !reachable { return nil } return TailscaleServeGatewayBeacon( displayName: candidate.displayName, tailnetDns: candidate.dnsName, host: candidate.dnsName, port: 443) } } for _ in 0.. [Candidate] { let selfDns = self.normalizeDnsName(status.selfNode?.dnsName) var out: [Candidate] = [] var seen = Set() for node in status.peer.values { if node.online == false { continue } guard let dnsName = normalizeDnsName(node.dnsName) else { continue } if dnsName == selfDns { continue } if seen.contains(dnsName) { continue } seen.insert(dnsName) out.append(Candidate( dnsName: dnsName, displayName: self.displayName(hostName: node.hostName, dnsName: dnsName))) if out.count >= self.maxCandidates { break } } return out } private static func displayName(hostName: String?, dnsName: String) -> String { if let hostName { let trimmed = hostName.trimmingCharacters(in: .whitespacesAndNewlines) if !trimmed.isEmpty { return trimmed } } return dnsName .split(separator: ".") .first .map(String.init) ?? dnsName } private static func normalizeDnsName(_ raw: String?) -> String? { guard let raw else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) if trimmed.isEmpty { return nil } let withoutDot = trimmed.hasSuffix(".") ? String(trimmed.dropLast()) : trimmed let lower = withoutDot.lowercased() return lower.isEmpty ? nil : lower } private static func readTailscaleStatus() async -> String? { let candidates = [ "/usr/local/bin/tailscale", "/opt/homebrew/bin/tailscale", "/Applications/Tailscale.app/Contents/MacOS/Tailscale", "tailscale", ] for candidate in candidates { guard let executable = self.resolveExecutablePath(candidate) else { continue } if let stdout = await self.run(path: executable, args: ["status", "--json"], timeout: 1.0) { return stdout } } return nil } static func resolveExecutablePath( _ candidate: String, env: [String: String] = ProcessInfo.processInfo.environment) -> String? { let trimmed = candidate.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return nil } let fileManager = FileManager.default let hasPathSeparator = trimmed.contains("/") if hasPathSeparator { return fileManager.isExecutableFile(atPath: trimmed) ? trimmed : nil } let pathRaw = env["PATH"] ?? "" let entries = pathRaw.split(separator: ":").map(String.init) for entry in entries { let dir = entry.trimmingCharacters(in: .whitespacesAndNewlines) if dir.isEmpty { continue } let fullPath = URL(fileURLWithPath: dir) .appendingPathComponent(trimmed) .path if fileManager.isExecutableFile(atPath: fullPath) { return fullPath } } return nil } private static func run(path: String, args: [String], timeout: TimeInterval) async -> String? { await withCheckedContinuation { continuation in DispatchQueue.global(qos: .utility).async { continuation.resume(returning: self.runBlocking(path: path, args: args, timeout: timeout)) } } } private static func runBlocking(path: String, args: [String], timeout: TimeInterval) -> String? { let process = Process() process.executableURL = URL(fileURLWithPath: path) process.arguments = args process.environment = self.commandEnvironment() let outPipe = Pipe() process.standardOutput = outPipe process.standardError = FileHandle.nullDevice do { try process.run() } catch { return nil } let deadline = Date().addingTimeInterval(timeout) while process.isRunning, Date() < deadline { Thread.sleep(forTimeInterval: 0.02) } if process.isRunning { process.terminate() } process.waitUntilExit() let data = (try? outPipe.fileHandleForReading.readToEnd()) ?? Data() let output = String(data: data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) return output?.isEmpty == false ? output : nil } static func commandEnvironment( base: [String: String] = ProcessInfo.processInfo.environment) -> [String: String] { var env = base let term = env["TERM"]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if term.isEmpty { // The macOS Tailscale app binary exits with CLIError error 3 when TERM is missing, // which is common for GUI-launched app environments. env["TERM"] = "dumb" } return env } private static func parseStatus(_ raw: String) -> TailscaleStatus? { guard let data = raw.data(using: .utf8) else { return nil } return try? JSONDecoder().decode(TailscaleStatus.self, from: data) } private static func probeHostForGatewayChallenge(host: String, timeout: TimeInterval) async -> Bool { var components = URLComponents() components.scheme = "wss" components.host = host guard let url = components.url else { return false } let config = URLSessionConfiguration.ephemeral config.timeoutIntervalForRequest = max(0.5, timeout) config.timeoutIntervalForResource = max(0.5, timeout) let session = URLSession(configuration: config) let task = session.webSocketTask(with: url) task.resume() defer { task.cancel(with: .goingAway, reason: nil) session.invalidateAndCancel() } do { return try await AsyncTimeout.withTimeout( seconds: timeout, onTimeout: { NSError(domain: "TailscaleServeDiscovery", code: 1, userInfo: nil) }, operation: { while true { let message = try await task.receive() if self.isConnectChallenge(message: message) { return true } } }) } catch { return false } } private static func isConnectChallenge(message: URLSessionWebSocketTask.Message) -> Bool { let data: Data switch message { case let .data(value): data = value case let .string(value): guard let encoded = value.data(using: .utf8) else { return false } data = encoded @unknown default: return false } guard let object = try? JSONSerialization.jsonObject(with: data), let dict = object as? [String: Any], let type = dict["type"] as? String, type == "event", let event = dict["event"] as? String else { return false } return event == "connect.challenge" } } private struct TailscaleStatus: Decodable { struct Node: Decodable { let dnsName: String? let hostName: String? let online: Bool? private enum CodingKeys: String, CodingKey { case dnsName = "DNSName" case hostName = "HostName" case online = "Online" } } let selfNode: Node? let peer: [String: Node] private enum CodingKeys: String, CodingKey { case selfNode = "Self" case peer = "Peer" } }