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

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