mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
macOS: add tailscale serve discovery fallback for remote gateways (#32860)
* feat(macos): add tailscale serve gateway discovery fallback * fix: add changelog note for tailscale serve discovery fallback (#32860) (thanks @ngutman)
This commit is contained in:
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- macOS/Tailscale remote gateway discovery: add a Tailscale Serve fallback peer probe path (`wss://<peer>.ts.net`) when Bonjour and wide-area DNS-SD discovery return no gateways, and refresh both discovery paths from macOS onboarding. (#32860) Thanks @ngutman.
|
||||
- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin.
|
||||
- Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras.
|
||||
- Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone.
|
||||
|
||||
@@ -134,10 +134,10 @@ extension OnboardingView {
|
||||
if self.gatewayDiscovery.gateways.isEmpty {
|
||||
ProgressView().controlSize(.small)
|
||||
Button("Refresh") {
|
||||
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
|
||||
self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0)
|
||||
}
|
||||
.buttonStyle(.link)
|
||||
.help("Retry Tailscale discovery (DNS-SD).")
|
||||
.help("Retry remote discovery (Tailscale DNS-SD + Serve probe).")
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ public final class GatewayDiscoveryModel {
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var wideAreaFallbackTask: Task<Void, Never>?
|
||||
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
|
||||
private var tailscaleServeFallbackTask: Task<Void, Never>?
|
||||
private var tailscaleServeFallbackGateways: [DiscoveredGateway] = []
|
||||
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
|
||||
|
||||
public init(
|
||||
@@ -111,6 +113,7 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
|
||||
self.scheduleWideAreaFallback()
|
||||
self.scheduleTailscaleServeFallback()
|
||||
}
|
||||
|
||||
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
@@ -126,6 +129,23 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshTailscaleServeFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRemoteFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
self.refreshWideAreaFallbackNow(timeoutSeconds: timeoutSeconds)
|
||||
self.refreshTailscaleServeFallbackNow(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
public func stop() {
|
||||
for browser in self.browsers.values {
|
||||
browser.cancel()
|
||||
@@ -140,6 +160,9 @@ public final class GatewayDiscoveryModel {
|
||||
self.wideAreaFallbackTask?.cancel()
|
||||
self.wideAreaFallbackTask = nil
|
||||
self.wideAreaFallbackGateways = []
|
||||
self.tailscaleServeFallbackTask?.cancel()
|
||||
self.tailscaleServeFallbackTask = nil
|
||||
self.tailscaleServeFallbackGateways = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
@@ -168,22 +191,45 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func mapTailscaleServeBeacons(
|
||||
_ beacons: [TailscaleServeGatewayBeacon]) -> [DiscoveredGateway]
|
||||
{
|
||||
beacons.map { beacon in
|
||||
let stableID = "tailscale-serve|\(beacon.tailnetDns.lowercased())"
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: nil,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
displayName: beacon.displayName,
|
||||
serviceName: nil,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: beacon.displayName,
|
||||
serviceHost: beacon.host,
|
||||
servicePort: beacon.port,
|
||||
lanHost: nil,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
sshPort: 22,
|
||||
gatewayPort: beacon.port,
|
||||
cliPath: nil,
|
||||
stableID: stableID,
|
||||
debugID: "\(beacon.host):\(beacon.port)",
|
||||
isLocal: isLocal)
|
||||
}
|
||||
}
|
||||
|
||||
private func recomputeGateways() {
|
||||
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
|
||||
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
|
||||
if !primaryFiltered.isEmpty {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
|
||||
// which makes onboarding look empty even though Tailscale DNS-SD can already see gateways.
|
||||
guard !self.wideAreaFallbackGateways.isEmpty else {
|
||||
// and cross-network setups may rely on Tailscale Serve without DNS-SD.
|
||||
let fallback = self.wideAreaFallbackGateways + self.tailscaleServeFallbackGateways
|
||||
guard !fallback.isEmpty else {
|
||||
self.gateways = primaryFiltered
|
||||
return
|
||||
}
|
||||
|
||||
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
|
||||
let combined = self.sortedDeduped(gateways: primary + fallback)
|
||||
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
|
||||
}
|
||||
|
||||
@@ -284,6 +330,39 @@ public final class GatewayDiscoveryModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleTailscaleServeFallback() {
|
||||
if Self.isRunningTests { return }
|
||||
guard self.tailscaleServeFallbackTask == nil else { return }
|
||||
self.tailscaleServeFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
var attempt = 0
|
||||
let startedAt = Date()
|
||||
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
|
||||
let hasResults = await MainActor.run {
|
||||
if self.filterLocalGateways {
|
||||
return !self.gateways.isEmpty
|
||||
}
|
||||
return self.gateways.contains(where: { !$0.isLocal })
|
||||
}
|
||||
if hasResults { return }
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
|
||||
if !beacons.isEmpty {
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
let backoff = min(8.0, 0.8 + (Double(attempt) * 0.8))
|
||||
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var hasUsableWideAreaResults: Bool {
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
|
||||
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||
@@ -291,11 +370,25 @@ public final class GatewayDiscoveryModel {
|
||||
return gateways.contains(where: { !$0.isLocal })
|
||||
}
|
||||
|
||||
static func dedupeKey(for gateway: DiscoveredGateway) -> String {
|
||||
if let host = gateway.serviceHost?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased(),
|
||||
!host.isEmpty,
|
||||
let port = gateway.servicePort,
|
||||
port > 0
|
||||
{
|
||||
return "endpoint|\(host):\(port)"
|
||||
}
|
||||
return "stable|\(gateway.stableID)"
|
||||
}
|
||||
|
||||
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
|
||||
var seen = Set<String>()
|
||||
let deduped = gateways.filter { gateway in
|
||||
if seen.contains(gateway.stableID) { return false }
|
||||
seen.insert(gateway.stableID)
|
||||
let key = Self.dedupeKey(for: gateway)
|
||||
if seen.contains(key) { return false }
|
||||
seen.insert(key)
|
||||
return true
|
||||
}
|
||||
return deduped.sorted {
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct TailscaleServeGatewayBeacon: Sendable, 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: Sendable {
|
||||
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..<workerCount {
|
||||
submitOne()
|
||||
}
|
||||
|
||||
while let beacon = await group.next() {
|
||||
if let beacon {
|
||||
byHost[beacon.host.lowercased()] = beacon
|
||||
}
|
||||
submitOne()
|
||||
}
|
||||
}
|
||||
|
||||
return byHost.values.sorted {
|
||||
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private struct Candidate: Sendable {
|
||||
var dnsName: String
|
||||
var displayName: String
|
||||
}
|
||||
|
||||
private static func collectCandidates(status: TailscaleStatus) -> [Candidate] {
|
||||
let selfDns = normalizeDnsName(status.selfNode?.dnsName)
|
||||
var out: [Candidate] = []
|
||||
var seen = Set<String>()
|
||||
|
||||
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: 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
|
||||
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
|
||||
}
|
||||
|
||||
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 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"
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import OpenClawDiscovery
|
||||
@testable import OpenClawDiscovery
|
||||
import Testing
|
||||
|
||||
@Suite
|
||||
@@ -121,4 +121,50 @@ struct GatewayDiscoveryModelTests {
|
||||
host: "studio.local",
|
||||
port: 2201) == "peter@studio.local:2201")
|
||||
}
|
||||
|
||||
@Test func dedupeKeyPrefersResolvedEndpointAcrossSources() {
|
||||
let wideArea = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "wide-area|openclaw.internal.|gateway-host",
|
||||
debugID: "wide-area",
|
||||
isLocal: false)
|
||||
let serve = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: "gateway-host.tailnet-example.ts.net",
|
||||
servicePort: 443,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: 443,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.dedupeKey(for: wideArea) == GatewayDiscoveryModel.dedupeKey(for: serve))
|
||||
}
|
||||
|
||||
@Test func dedupeKeyFallsBackToStableIDWithoutEndpoint() {
|
||||
let unresolved = GatewayDiscoveryModel.DiscoveredGateway(
|
||||
displayName: "Gateway",
|
||||
serviceHost: nil,
|
||||
servicePort: nil,
|
||||
lanHost: nil,
|
||||
tailnetDns: "gateway-host.tailnet-example.ts.net",
|
||||
sshPort: 22,
|
||||
gatewayPort: nil,
|
||||
cliPath: nil,
|
||||
stableID: "tailscale-serve|gateway-host.tailnet-example.ts.net",
|
||||
debugID: "serve",
|
||||
isLocal: false)
|
||||
|
||||
#expect(GatewayDiscoveryModel.dedupeKey(for: unresolved) == "stable|tailscale-serve|gateway-host.tailnet-example.ts.net")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import OpenClawDiscovery
|
||||
|
||||
@Suite
|
||||
struct TailscaleServeGatewayDiscoveryTests {
|
||||
@Test func discoversServeGatewayFromTailnetPeers() async {
|
||||
let statusJson = """
|
||||
{
|
||||
"Self": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
},
|
||||
"Peer": {
|
||||
"peer-1": {
|
||||
"DNSName": "gateway-host.tailnet-example.ts.net.",
|
||||
"HostName": "gateway-host",
|
||||
"Online": true
|
||||
},
|
||||
"peer-2": {
|
||||
"DNSName": "offline.tailnet-example.ts.net.",
|
||||
"HostName": "offline-box",
|
||||
"Online": false
|
||||
},
|
||||
"peer-3": {
|
||||
"DNSName": "local-mac.tailnet-example.ts.net.",
|
||||
"HostName": "local-mac",
|
||||
"Online": true
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { statusJson },
|
||||
probeHost: { host, _ in
|
||||
host == "gateway-host.tailnet-example.ts.net"
|
||||
})
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.count == 1)
|
||||
#expect(beacons.first?.displayName == "gateway-host")
|
||||
#expect(beacons.first?.tailnetDns == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.host == "gateway-host.tailnet-example.ts.net")
|
||||
#expect(beacons.first?.port == 443)
|
||||
}
|
||||
|
||||
@Test func returnsEmptyWhenStatusUnavailable() async {
|
||||
let context = TailscaleServeGatewayDiscovery.DiscoveryContext(
|
||||
tailscaleStatus: { nil },
|
||||
probeHost: { _, _ in true })
|
||||
|
||||
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.0, context: context)
|
||||
#expect(beacons.isEmpty)
|
||||
}
|
||||
|
||||
@Test func resolvesBareExecutableFromPATH() throws {
|
||||
let tempDir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString)
|
||||
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: tempDir) }
|
||||
|
||||
let executable = tempDir.appendingPathComponent("tailscale")
|
||||
try "#!/bin/sh\necho ok\n".write(to: executable, atomically: true, encoding: .utf8)
|
||||
try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: executable.path)
|
||||
|
||||
let env: [String: String] = ["PATH": tempDir.path]
|
||||
let resolved = TailscaleServeGatewayDiscovery.resolveExecutablePath("tailscale", env: env)
|
||||
#expect(resolved == executable.path)
|
||||
}
|
||||
|
||||
@Test func rejectsMissingExecutableCandidate() {
|
||||
#expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("", env: [:]) == nil)
|
||||
#expect(TailscaleServeGatewayDiscovery.resolveExecutablePath("definitely-not-here", env: ["PATH": "/tmp"]) == nil)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user