Skip to content

Commit 4aa548c

Browse files
authored
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)
1 parent 4ffe15c commit 4aa548c

File tree

6 files changed

+544
-12
lines changed

6 files changed

+544
-12
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai
1111

1212
### Fixes
1313

14+
- 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.
1415
- 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.
1516
- 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.
1617
- 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.

apps/macos/Sources/OpenClaw/OnboardingView+Pages.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,10 +134,10 @@ extension OnboardingView {
134134
if self.gatewayDiscovery.gateways.isEmpty {
135135
ProgressView().controlSize(.small)
136136
Button("Refresh") {
137-
self.gatewayDiscovery.refreshWideAreaFallbackNow(timeoutSeconds: 5.0)
137+
self.gatewayDiscovery.refreshRemoteFallbackNow(timeoutSeconds: 5.0)
138138
}
139139
.buttonStyle(.link)
140-
.help("Retry Tailscale discovery (DNS-SD).")
140+
.help("Retry remote discovery (Tailscale DNS-SD + Serve probe).")
141141
}
142142
Spacer(minLength: 0)
143143
}

apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ public final class GatewayDiscoveryModel {
7676
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
7777
private var wideAreaFallbackTask: Task<Void, Never>?
7878
private var wideAreaFallbackGateways: [DiscoveredGateway] = []
79+
private var tailscaleServeFallbackTask: Task<Void, Never>?
80+
private var tailscaleServeFallbackGateways: [DiscoveredGateway] = []
7981
private let logger = Logger(subsystem: "ai.openclaw", category: "gateway-discovery")
8082

8183
public init(
@@ -111,6 +113,7 @@ public final class GatewayDiscoveryModel {
111113
}
112114

113115
self.scheduleWideAreaFallback()
116+
self.scheduleTailscaleServeFallback()
114117
}
115118

116119
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
@@ -126,6 +129,23 @@ public final class GatewayDiscoveryModel {
126129
}
127130
}
128131

132+
public func refreshTailscaleServeFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
133+
Task.detached(priority: .utility) { [weak self] in
134+
guard let self else { return }
135+
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
136+
await MainActor.run { [weak self] in
137+
guard let self else { return }
138+
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
139+
self.recomputeGateways()
140+
}
141+
}
142+
}
143+
144+
public func refreshRemoteFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
145+
self.refreshWideAreaFallbackNow(timeoutSeconds: timeoutSeconds)
146+
self.refreshTailscaleServeFallbackNow(timeoutSeconds: timeoutSeconds)
147+
}
148+
129149
public func stop() {
130150
for browser in self.browsers.values {
131151
browser.cancel()
@@ -140,6 +160,9 @@ public final class GatewayDiscoveryModel {
140160
self.wideAreaFallbackTask?.cancel()
141161
self.wideAreaFallbackTask = nil
142162
self.wideAreaFallbackGateways = []
163+
self.tailscaleServeFallbackTask?.cancel()
164+
self.tailscaleServeFallbackTask = nil
165+
self.tailscaleServeFallbackGateways = []
143166
self.gateways = []
144167
self.statusText = "Stopped"
145168
}
@@ -168,22 +191,45 @@ public final class GatewayDiscoveryModel {
168191
}
169192
}
170193

194+
private func mapTailscaleServeBeacons(
195+
_ beacons: [TailscaleServeGatewayBeacon]) -> [DiscoveredGateway]
196+
{
197+
beacons.map { beacon in
198+
let stableID = "tailscale-serve|\(beacon.tailnetDns.lowercased())"
199+
let isLocal = Self.isLocalGateway(
200+
lanHost: nil,
201+
tailnetDns: beacon.tailnetDns,
202+
displayName: beacon.displayName,
203+
serviceName: nil,
204+
local: self.localIdentity)
205+
return DiscoveredGateway(
206+
displayName: beacon.displayName,
207+
serviceHost: beacon.host,
208+
servicePort: beacon.port,
209+
lanHost: nil,
210+
tailnetDns: beacon.tailnetDns,
211+
sshPort: 22,
212+
gatewayPort: beacon.port,
213+
cliPath: nil,
214+
stableID: stableID,
215+
debugID: "\(beacon.host):\(beacon.port)",
216+
isLocal: isLocal)
217+
}
218+
}
219+
171220
private func recomputeGateways() {
172221
let primary = self.sortedDeduped(gateways: self.gatewaysByDomain.values.flatMap(\.self))
173222
let primaryFiltered = self.filterLocalGateways ? primary.filter { !$0.isLocal } : primary
174-
if !primaryFiltered.isEmpty {
175-
self.gateways = primaryFiltered
176-
return
177-
}
178223

179224
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
180-
// which makes onboarding look empty even though Tailscale DNS-SD can already see gateways.
181-
guard !self.wideAreaFallbackGateways.isEmpty else {
225+
// and cross-network setups may rely on Tailscale Serve without DNS-SD.
226+
let fallback = self.wideAreaFallbackGateways + self.tailscaleServeFallbackGateways
227+
guard !fallback.isEmpty else {
182228
self.gateways = primaryFiltered
183229
return
184230
}
185231

186-
let combined = self.sortedDeduped(gateways: primary + self.wideAreaFallbackGateways)
232+
let combined = self.sortedDeduped(gateways: primary + fallback)
187233
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
188234
}
189235

@@ -284,18 +330,65 @@ public final class GatewayDiscoveryModel {
284330
}
285331
}
286332

333+
private func scheduleTailscaleServeFallback() {
334+
if Self.isRunningTests { return }
335+
guard self.tailscaleServeFallbackTask == nil else { return }
336+
self.tailscaleServeFallbackTask = Task.detached(priority: .utility) { [weak self] in
337+
guard let self else { return }
338+
var attempt = 0
339+
let startedAt = Date()
340+
while !Task.isCancelled, Date().timeIntervalSince(startedAt) < 35.0 {
341+
let hasResults = await MainActor.run {
342+
if self.filterLocalGateways {
343+
return !self.gateways.isEmpty
344+
}
345+
return self.gateways.contains(where: { !$0.isLocal })
346+
}
347+
if hasResults { return }
348+
349+
let beacons = await TailscaleServeGatewayDiscovery.discover(timeoutSeconds: 2.4)
350+
if !beacons.isEmpty {
351+
await MainActor.run { [weak self] in
352+
guard let self else { return }
353+
self.tailscaleServeFallbackGateways = self.mapTailscaleServeBeacons(beacons)
354+
self.recomputeGateways()
355+
}
356+
return
357+
}
358+
359+
attempt += 1
360+
let backoff = min(8.0, 0.8 + (Double(attempt) * 0.8))
361+
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
362+
}
363+
}
364+
}
365+
287366
private var hasUsableWideAreaResults: Bool {
288367
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
289368
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
290369
if !self.filterLocalGateways { return true }
291370
return gateways.contains(where: { !$0.isLocal })
292371
}
293372

373+
static func dedupeKey(for gateway: DiscoveredGateway) -> String {
374+
if let host = gateway.serviceHost?
375+
.trimmingCharacters(in: .whitespacesAndNewlines)
376+
.lowercased(),
377+
!host.isEmpty,
378+
let port = gateway.servicePort,
379+
port > 0
380+
{
381+
return "endpoint|\(host):\(port)"
382+
}
383+
return "stable|\(gateway.stableID)"
384+
}
385+
294386
private func sortedDeduped(gateways: [DiscoveredGateway]) -> [DiscoveredGateway] {
295387
var seen = Set<String>()
296388
let deduped = gateways.filter { gateway in
297-
if seen.contains(gateway.stableID) { return false }
298-
seen.insert(gateway.stableID)
389+
let key = Self.dedupeKey(for: gateway)
390+
if seen.contains(key) { return false }
391+
seen.insert(key)
299392
return true
300393
}
301394
return deduped.sorted {

0 commit comments

Comments
 (0)