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:
Nimrod Gutman
2026-03-03 13:25:36 +02:00
committed by GitHub
parent 4ffe15c6b2
commit 4aa548cf7d
6 changed files with 544 additions and 12 deletions

View File

@@ -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.

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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"
}
}

View File

@@ -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")
}
}

View File

@@ -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)
}
}