Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
797
apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
Normal file
797
apps/macos/Sources/OpenClawDiscovery/GatewayDiscoveryModel.swift
Normal file
@@ -0,0 +1,797 @@
|
||||
import Foundation
|
||||
import Network
|
||||
import Observation
|
||||
import OpenClawKit
|
||||
import OSLog
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
public final class GatewayDiscoveryModel {
|
||||
public struct LocalIdentity: Equatable, Sendable {
|
||||
public var hostTokens: Set<String>
|
||||
public var displayTokens: Set<String>
|
||||
|
||||
public init(hostTokens: Set<String>, displayTokens: Set<String>) {
|
||||
self.hostTokens = hostTokens
|
||||
self.displayTokens = displayTokens
|
||||
}
|
||||
}
|
||||
|
||||
public struct DiscoveredGateway: Identifiable, Equatable, Sendable {
|
||||
public var id: String {
|
||||
self.stableID
|
||||
}
|
||||
|
||||
public var displayName: String
|
||||
// Resolved service endpoint (SRV + A/AAAA). Used for routing; do not trust TXT for routing.
|
||||
public var serviceHost: String?
|
||||
public var servicePort: Int?
|
||||
public var lanHost: String?
|
||||
public var tailnetDns: String?
|
||||
public var sshPort: Int
|
||||
public var gatewayPort: Int?
|
||||
public var gatewayTls: Bool
|
||||
public var gatewayDirectReachable: Bool
|
||||
public var cliPath: String?
|
||||
public var stableID: String
|
||||
public var debugID: String
|
||||
public var isLocal: Bool
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
serviceHost: String? = nil,
|
||||
servicePort: Int? = nil,
|
||||
lanHost: String? = nil,
|
||||
tailnetDns: String? = nil,
|
||||
sshPort: Int,
|
||||
gatewayPort: Int? = nil,
|
||||
gatewayTls: Bool = false,
|
||||
gatewayDirectReachable: Bool = false,
|
||||
cliPath: String? = nil,
|
||||
stableID: String,
|
||||
debugID: String,
|
||||
isLocal: Bool)
|
||||
{
|
||||
self.displayName = displayName
|
||||
self.serviceHost = serviceHost
|
||||
self.servicePort = servicePort
|
||||
self.lanHost = lanHost
|
||||
self.tailnetDns = tailnetDns
|
||||
self.sshPort = sshPort
|
||||
self.gatewayPort = gatewayPort
|
||||
self.gatewayTls = gatewayTls
|
||||
self.gatewayDirectReachable = gatewayDirectReachable
|
||||
self.cliPath = cliPath
|
||||
self.stableID = stableID
|
||||
self.debugID = debugID
|
||||
self.isLocal = isLocal
|
||||
}
|
||||
}
|
||||
|
||||
public var gateways: [DiscoveredGateway] = []
|
||||
public var statusText: String = "Idle"
|
||||
|
||||
private var browsers: [String: NWBrowser] = [:]
|
||||
private var resultsByDomain: [String: Set<NWBrowser.Result>] = [:]
|
||||
private var gatewaysByDomain: [String: [DiscoveredGateway]] = [:]
|
||||
private var statesByDomain: [String: NWBrowser.State] = [:]
|
||||
private var localIdentity: LocalIdentity
|
||||
private let localDisplayName: String?
|
||||
private let filterLocalGateways: Bool
|
||||
private var resolvedServiceByID: [String: ResolvedGatewayService] = [:]
|
||||
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(
|
||||
localDisplayName: String? = nil,
|
||||
filterLocalGateways: Bool = true)
|
||||
{
|
||||
self.localDisplayName = localDisplayName
|
||||
self.filterLocalGateways = filterLocalGateways
|
||||
self.localIdentity = Self.buildLocalIdentityFast(displayName: localDisplayName)
|
||||
self.refreshLocalIdentity()
|
||||
}
|
||||
|
||||
public func start() {
|
||||
if !self.browsers.isEmpty { return }
|
||||
|
||||
for domain in OpenClawBonjour.gatewayServiceDomains {
|
||||
let browser = GatewayDiscoveryBrowserSupport.makeBrowser(
|
||||
serviceType: OpenClawBonjour.gatewayServiceType,
|
||||
domain: domain,
|
||||
queueLabelPrefix: "ai.openclaw.macos.gateway-discovery",
|
||||
onState: { [weak self] state in
|
||||
guard let self else { return }
|
||||
self.statesByDomain[domain] = state
|
||||
self.updateStatusText()
|
||||
},
|
||||
onResults: { [weak self] results in
|
||||
guard let self else { return }
|
||||
self.resultsByDomain[domain] = results
|
||||
self.updateGateways(for: domain)
|
||||
self.recomputeGateways()
|
||||
})
|
||||
self.browsers[domain] = browser
|
||||
}
|
||||
|
||||
self.scheduleWideAreaFallback()
|
||||
self.scheduleTailscaleServeFallback()
|
||||
}
|
||||
|
||||
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return }
|
||||
Task.detached(priority: .utility) { [weak self] in
|
||||
guard let self else { return }
|
||||
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
self.browsers = [:]
|
||||
self.resultsByDomain = [:]
|
||||
self.gatewaysByDomain = [:]
|
||||
self.statesByDomain = [:]
|
||||
self.resolvedServiceByID = [:]
|
||||
self.pendingServiceResolvers.values.forEach { $0.cancel() }
|
||||
self.pendingServiceResolvers = [:]
|
||||
self.wideAreaFallbackTask?.cancel()
|
||||
self.wideAreaFallbackTask = nil
|
||||
self.wideAreaFallbackGateways = []
|
||||
self.tailscaleServeFallbackTask?.cancel()
|
||||
self.tailscaleServeFallbackTask = nil
|
||||
self.tailscaleServeFallbackGateways = []
|
||||
self.gateways = []
|
||||
self.statusText = "Stopped"
|
||||
}
|
||||
|
||||
private func mapWideAreaBeacons(_ beacons: [WideAreaGatewayBeacon], domain: String) -> [DiscoveredGateway] {
|
||||
beacons.map { beacon in
|
||||
let stableID = "wide-area|\(domain)|\(beacon.instanceName)"
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: beacon.lanHost,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
displayName: beacon.displayName,
|
||||
serviceName: beacon.instanceName,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: beacon.displayName,
|
||||
serviceHost: beacon.host,
|
||||
servicePort: beacon.port,
|
||||
lanHost: beacon.lanHost,
|
||||
tailnetDns: beacon.tailnetDns,
|
||||
sshPort: beacon.sshPort ?? 22,
|
||||
gatewayPort: beacon.gatewayPort,
|
||||
gatewayTls: beacon.gatewayTls,
|
||||
gatewayDirectReachable: beacon.gatewayDirectReachable,
|
||||
cliPath: beacon.cliPath,
|
||||
stableID: stableID,
|
||||
debugID: "\(beacon.instanceName)@\(beacon.host):\(beacon.port)",
|
||||
isLocal: isLocal)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
gatewayTls: true,
|
||||
gatewayDirectReachable: true,
|
||||
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
|
||||
|
||||
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
|
||||
// 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 + fallback)
|
||||
self.gateways = self.filterLocalGateways ? combined.filter { !$0.isLocal } : combined
|
||||
}
|
||||
|
||||
private func updateGateways(for domain: String) {
|
||||
guard let results = self.resultsByDomain[domain] else {
|
||||
self.gatewaysByDomain[domain] = []
|
||||
return
|
||||
}
|
||||
|
||||
self.gatewaysByDomain[domain] = results.compactMap { result -> DiscoveredGateway? in
|
||||
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
|
||||
|
||||
let decodedName = BonjourEscapes.decode(name)
|
||||
let stableID = GatewayEndpointID.stableID(result.endpoint)
|
||||
let resolved = self.resolvedServiceByID[stableID]
|
||||
let resolvedTXT = resolved?.txt ?? [:]
|
||||
let txt = Self.txtDictionary(from: result).merging(
|
||||
resolvedTXT,
|
||||
uniquingKeysWith: { _, new in new })
|
||||
|
||||
let advertisedName = txt["displayName"]
|
||||
.map(Self.prettifyInstanceName)
|
||||
.flatMap { $0.isEmpty ? nil : $0 }
|
||||
let prettyName =
|
||||
advertisedName ?? Self.prettifyServiceName(decodedName)
|
||||
|
||||
let parsedTXT = Self.parseGatewayTXT(txt)
|
||||
|
||||
// Always attempt NetService resolution for the endpoint (host/port and TXT).
|
||||
// TXT is unauthenticated; do not use it for routing.
|
||||
if resolved == nil {
|
||||
self.ensureServiceResolution(
|
||||
stableID: stableID,
|
||||
serviceName: name,
|
||||
type: type,
|
||||
domain: resultDomain)
|
||||
}
|
||||
|
||||
let isLocal = Self.isLocalGateway(
|
||||
lanHost: parsedTXT.lanHost,
|
||||
tailnetDns: parsedTXT.tailnetDns,
|
||||
displayName: prettyName,
|
||||
serviceName: decodedName,
|
||||
local: self.localIdentity)
|
||||
return DiscoveredGateway(
|
||||
displayName: prettyName,
|
||||
serviceHost: resolved?.host,
|
||||
servicePort: resolved?.port,
|
||||
lanHost: parsedTXT.lanHost,
|
||||
tailnetDns: parsedTXT.tailnetDns,
|
||||
sshPort: parsedTXT.sshPort,
|
||||
gatewayPort: parsedTXT.gatewayPort,
|
||||
gatewayTls: parsedTXT.gatewayTls,
|
||||
gatewayDirectReachable: parsedTXT.gatewayDirectReachable,
|
||||
cliPath: parsedTXT.cliPath,
|
||||
stableID: stableID,
|
||||
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
|
||||
isLocal: isLocal)
|
||||
}
|
||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||
|
||||
if let wideAreaDomain = OpenClawBonjour.wideAreaGatewayServiceDomain,
|
||||
domain == wideAreaDomain,
|
||||
self.hasUsableWideAreaResults
|
||||
{
|
||||
self.wideAreaFallbackGateways = []
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleWideAreaFallback() {
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return }
|
||||
if Self.isRunningTests { return }
|
||||
guard self.wideAreaFallbackTask == nil else { return }
|
||||
self.wideAreaFallbackTask = 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 {
|
||||
self.hasUsableWideAreaResults
|
||||
}
|
||||
if hasResults { return }
|
||||
|
||||
// Wide-area discovery can be racy (Tailscale not yet up, DNS zone not
|
||||
// published yet). Retry with a short backoff while onboarding is open.
|
||||
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: 2.0)
|
||||
if !beacons.isEmpty {
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.wideAreaFallbackGateways = self.mapWideAreaBeacons(beacons, domain: domain)
|
||||
self.recomputeGateways()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
attempt += 1
|
||||
let backoff = min(8.0, 0.6 + (Double(attempt) * 0.7))
|
||||
try? await Task.sleep(nanoseconds: UInt64(backoff * 1_000_000_000))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 shouldContinue = await MainActor.run {
|
||||
Self.shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways: self.gateways,
|
||||
tailscaleServeGateways: self.tailscaleServeFallbackGateways)
|
||||
}
|
||||
if !shouldContinue { 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static func shouldContinueTailscaleServeDiscovery(
|
||||
currentGateways _: [DiscoveredGateway],
|
||||
tailscaleServeGateways: [DiscoveredGateway]) -> Bool
|
||||
{
|
||||
// Tailscale Serve is a parallel discovery source. DNS-SD results should not suppress the
|
||||
// probe, otherwise Serve-only gateways disappear as soon as any other remote gateway is found.
|
||||
tailscaleServeGateways.isEmpty
|
||||
}
|
||||
|
||||
private var hasUsableWideAreaResults: Bool {
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return false }
|
||||
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||
if !self.filterLocalGateways { return true }
|
||||
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
|
||||
let key = Self.dedupeKey(for: gateway)
|
||||
if seen.contains(key) { return false }
|
||||
seen.insert(key)
|
||||
return true
|
||||
}
|
||||
return deduped.sorted {
|
||||
$0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static var isRunningTests: Bool {
|
||||
// Keep discovery background work from running forever during SwiftPM test runs.
|
||||
if Bundle.allBundles.contains(where: { $0.bundleURL.pathExtension == "xctest" }) { return true }
|
||||
|
||||
let env = ProcessInfo.processInfo.environment
|
||||
return env["XCTestConfigurationFilePath"] != nil
|
||||
|| env["XCTestBundlePath"] != nil
|
||||
|| env["XCTestSessionIdentifier"] != nil
|
||||
}
|
||||
|
||||
private func updateGatewaysForAllDomains() {
|
||||
for domain in self.resultsByDomain.keys {
|
||||
self.updateGateways(for: domain)
|
||||
}
|
||||
}
|
||||
|
||||
private func updateStatusText() {
|
||||
self.statusText = GatewayDiscoveryStatusText.make(
|
||||
states: Array(self.statesByDomain.values),
|
||||
hasBrowsers: !self.browsers.isEmpty)
|
||||
}
|
||||
|
||||
private static func txtDictionary(from result: NWBrowser.Result) -> [String: String] {
|
||||
var merged: [String: String] = [:]
|
||||
|
||||
if case let .bonjour(txt) = result.metadata {
|
||||
merged.merge(txt.dictionary, uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
|
||||
if let endpointTxt = result.endpoint.txtRecord?.dictionary {
|
||||
merged.merge(endpointTxt, uniquingKeysWith: { _, new in new })
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
public struct GatewayTXT: Equatable {
|
||||
public var lanHost: String?
|
||||
public var tailnetDns: String?
|
||||
public var sshPort: Int
|
||||
public var gatewayPort: Int?
|
||||
public var gatewayTls: Bool
|
||||
public var gatewayDirectReachable: Bool
|
||||
public var cliPath: String?
|
||||
}
|
||||
|
||||
public static func parseGatewayTXT(_ txt: [String: String]) -> GatewayTXT {
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var sshPort = 22
|
||||
var gatewayPort: Int?
|
||||
var gatewayTls = false
|
||||
var gatewayDirectReachable = false
|
||||
var cliPath: String?
|
||||
|
||||
if let value = txt["lanHost"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
lanHost = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = txt["tailnetDns"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
tailnetDns = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
if let value = txt["sshPort"],
|
||||
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
sshPort = parsed
|
||||
}
|
||||
if let value = txt["gatewayPort"],
|
||||
let parsed = Int(value.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0
|
||||
{
|
||||
gatewayPort = parsed
|
||||
}
|
||||
if let value = txt["gatewayTls"] {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
gatewayTls = normalized == "1" || normalized == "true" || normalized == "yes"
|
||||
}
|
||||
if let value = txt["gatewayDirectReachable"] {
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
gatewayDirectReachable = normalized == "1" || normalized == "true" || normalized == "yes"
|
||||
}
|
||||
if let value = txt["cliPath"] {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
cliPath = trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
|
||||
return GatewayTXT(
|
||||
lanHost: lanHost,
|
||||
tailnetDns: tailnetDns,
|
||||
sshPort: sshPort,
|
||||
gatewayPort: gatewayPort,
|
||||
gatewayTls: gatewayTls,
|
||||
gatewayDirectReachable: gatewayDirectReachable,
|
||||
cliPath: cliPath)
|
||||
}
|
||||
|
||||
public static func buildSSHTarget(user: String, host: String, port: Int) -> String {
|
||||
var target = "\(user)@\(host)"
|
||||
if port != 22 {
|
||||
target += ":\(port)"
|
||||
}
|
||||
return target
|
||||
}
|
||||
|
||||
private func ensureServiceResolution(
|
||||
stableID: String,
|
||||
serviceName: String,
|
||||
type: String,
|
||||
domain: String)
|
||||
{
|
||||
guard self.resolvedServiceByID[stableID] == nil else { return }
|
||||
guard self.pendingServiceResolvers[stableID] == nil else { return }
|
||||
|
||||
let resolver = GatewayServiceResolver(
|
||||
name: serviceName,
|
||||
type: type,
|
||||
domain: domain,
|
||||
logger: self.logger)
|
||||
{ [weak self] result in
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.pendingServiceResolvers[stableID] = nil
|
||||
switch result {
|
||||
case let .success(resolved):
|
||||
self.resolvedServiceByID[stableID] = resolved
|
||||
self.updateGatewaysForAllDomains()
|
||||
self.recomputeGateways()
|
||||
case .failure:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.pendingServiceResolvers[stableID] = resolver
|
||||
resolver.start()
|
||||
}
|
||||
|
||||
private nonisolated static func prettifyInstanceName(_ decodedName: String) -> String {
|
||||
let normalized = decodedName.split(whereSeparator: \.isWhitespace).joined(separator: " ")
|
||||
let stripped = normalized.replacingOccurrences(of: " (OpenClaw)", with: "")
|
||||
.replacingOccurrences(of: #"\s+\(\d+\)$"#, with: "", options: .regularExpression)
|
||||
return stripped.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
|
||||
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
|
||||
let normalized = Self.prettifyInstanceName(decodedName)
|
||||
var cleaned = normalized.replacingOccurrences(of: #"\s*-?gateway$"#, with: "", options: .regularExpression)
|
||||
cleaned = cleaned
|
||||
.replacingOccurrences(of: "_", with: " ")
|
||||
.replacingOccurrences(of: "-", with: " ")
|
||||
.replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if cleaned.isEmpty {
|
||||
cleaned = normalized
|
||||
}
|
||||
let words = cleaned.split(separator: " ")
|
||||
let titled = words.map { word -> String in
|
||||
let lower = word.lowercased()
|
||||
guard let first = lower.first else { return "" }
|
||||
return String(first).uppercased() + lower.dropFirst()
|
||||
}.joined(separator: " ")
|
||||
return titled.isEmpty ? normalized : titled
|
||||
}
|
||||
|
||||
public nonisolated static func isLocalGateway(
|
||||
lanHost: String?,
|
||||
tailnetDns: String?,
|
||||
displayName: String?,
|
||||
serviceName: String?,
|
||||
local: LocalIdentity) -> Bool
|
||||
{
|
||||
if let host = normalizeHostToken(lanHost),
|
||||
local.hostTokens.contains(host)
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let host = normalizeHostToken(tailnetDns),
|
||||
local.hostTokens.contains(host)
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let name = normalizeDisplayToken(displayName),
|
||||
local.displayTokens.contains(name)
|
||||
{
|
||||
return true
|
||||
}
|
||||
if let serviceHost = normalizeServiceHostToken(serviceName),
|
||||
local.hostTokens.contains(serviceHost)
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func refreshLocalIdentity() {
|
||||
let fastIdentity = self.localIdentity
|
||||
let displayName = self.localDisplayName
|
||||
Task.detached(priority: .utility) {
|
||||
let slowIdentity = Self.buildLocalIdentitySlow(displayName: displayName)
|
||||
let merged = Self.mergeLocalIdentity(fast: fastIdentity, slow: slowIdentity)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
guard self.localIdentity != merged else { return }
|
||||
self.localIdentity = merged
|
||||
self.recomputeGateways()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func mergeLocalIdentity(
|
||||
fast: LocalIdentity,
|
||||
slow: LocalIdentity) -> LocalIdentity
|
||||
{
|
||||
LocalIdentity(
|
||||
hostTokens: fast.hostTokens.union(slow.hostTokens),
|
||||
displayTokens: fast.displayTokens.union(slow.displayTokens))
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentityFast(displayName: String?) -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
let hostName = ProcessInfo.processInfo.hostName
|
||||
if let token = normalizeHostToken(hostName) {
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(displayName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||
}
|
||||
|
||||
private nonisolated static func buildLocalIdentitySlow(displayName: String?) -> LocalIdentity {
|
||||
var hostTokens: Set<String> = []
|
||||
var displayTokens: Set<String> = []
|
||||
|
||||
if let host = Host.current().name,
|
||||
let token = normalizeHostToken(host)
|
||||
{
|
||||
hostTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(displayName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
if let token = normalizeDisplayToken(Host.current().localizedName) {
|
||||
displayTokens.insert(token)
|
||||
}
|
||||
|
||||
return LocalIdentity(hostTokens: hostTokens, displayTokens: displayTokens)
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeHostToken(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
let lower = trimmed.lowercased()
|
||||
let strippedTrailingDot = lower.hasSuffix(".")
|
||||
? String(lower.dropLast())
|
||||
: lower
|
||||
let withoutLocal = strippedTrailingDot.hasSuffix(".local")
|
||||
? String(strippedTrailingDot.dropLast(6))
|
||||
: strippedTrailingDot
|
||||
let firstLabel = withoutLocal.split(separator: ".").first.map(String.init)
|
||||
let token = (firstLabel ?? withoutLocal).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return token.isEmpty ? nil : token
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeDisplayToken(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let prettified = Self.prettifyInstanceName(raw)
|
||||
let trimmed = prettified.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return nil }
|
||||
return trimmed.lowercased()
|
||||
}
|
||||
|
||||
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
|
||||
guard let raw else { return nil }
|
||||
let prettified = Self.prettifyInstanceName(raw)
|
||||
let strippedGateway = prettified.replacingOccurrences(
|
||||
of: #"\s*-?\s*gateway$"#,
|
||||
with: "",
|
||||
options: .regularExpression)
|
||||
return self.normalizeHostToken(strippedGateway)
|
||||
}
|
||||
}
|
||||
|
||||
struct ResolvedGatewayService: Equatable {
|
||||
var txt: [String: String]
|
||||
var host: String?
|
||||
var port: Int?
|
||||
}
|
||||
|
||||
final class GatewayServiceResolver: NSObject, NetServiceDelegate {
|
||||
private let service: NetService
|
||||
private let completion: (Result<ResolvedGatewayService, Error>) -> Void
|
||||
private let logger: Logger
|
||||
private var didFinish = false
|
||||
|
||||
init(
|
||||
name: String,
|
||||
type: String,
|
||||
domain: String,
|
||||
logger: Logger,
|
||||
completion: @escaping (Result<ResolvedGatewayService, Error>) -> Void)
|
||||
{
|
||||
self.service = NetService(domain: domain, type: type, name: name)
|
||||
self.completion = completion
|
||||
self.logger = logger
|
||||
super.init()
|
||||
self.service.delegate = self
|
||||
}
|
||||
|
||||
func start(timeout: TimeInterval = 2.0) {
|
||||
BonjourServiceResolverSupport.start(self.service, timeout: timeout)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
self.finish(result: .failure(GatewayServiceResolverError.cancelled))
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ sender: NetService) {
|
||||
let txt = Self.decodeTXT(sender.txtRecordData())
|
||||
let host = Self.normalizeHost(sender.hostName)
|
||||
let port = sender.port > 0 ? sender.port : nil
|
||||
if !txt.isEmpty {
|
||||
let payload = self.formatTXT(txt)
|
||||
self.logger.debug(
|
||||
"discovery: resolved TXT for \(sender.name, privacy: .public): \(payload, privacy: .public)")
|
||||
}
|
||||
let resolved = ResolvedGatewayService(txt: txt, host: host, port: port)
|
||||
self.finish(result: .success(resolved))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
self.finish(result: .failure(GatewayServiceResolverError.resolveFailed(errorDict)))
|
||||
}
|
||||
|
||||
private func finish(result: Result<ResolvedGatewayService, Error>) {
|
||||
guard !self.didFinish else { return }
|
||||
self.didFinish = true
|
||||
self.service.stop()
|
||||
self.service.remove(from: .main, forMode: .common)
|
||||
self.completion(result)
|
||||
}
|
||||
|
||||
private static func decodeTXT(_ data: Data?) -> [String: String] {
|
||||
guard let data else { return [:] }
|
||||
let dict = NetService.dictionary(fromTXTRecord: data)
|
||||
var out: [String: String] = [:]
|
||||
out.reserveCapacity(dict.count)
|
||||
for (key, value) in dict {
|
||||
if let str = String(data: value, encoding: .utf8) {
|
||||
out[key] = str
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func normalizeHost(_ raw: String?) -> String? {
|
||||
BonjourServiceResolverSupport.normalizeHost(raw)
|
||||
}
|
||||
|
||||
private func formatTXT(_ txt: [String: String]) -> String {
|
||||
txt.sorted(by: { $0.key < $1.key })
|
||||
.map { "\($0.key)=\($0.value)" }
|
||||
.joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
enum GatewayServiceResolverError: Error {
|
||||
case cancelled
|
||||
case resolveFailed([String: NSNumber])
|
||||
}
|
||||
21
apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift
Normal file
21
apps/macos/Sources/OpenClawDiscovery/TailscaleNetwork.swift
Normal file
@@ -0,0 +1,21 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
public enum TailscaleNetwork {
|
||||
public static func isTailnetIPv4(_ address: String) -> Bool {
|
||||
let parts = address.split(separator: ".")
|
||||
guard parts.count == 4 else { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
guard octets.count == 4 else { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
public static func detectTailnetIPv4() -> String? {
|
||||
for entry in NetworkInterfaceIPv4.addresses() where self.isTailnetIPv4(entry.ip) {
|
||||
return entry.ip
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
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..<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 {
|
||||
var dnsName: String
|
||||
var displayName: String
|
||||
}
|
||||
|
||||
private static func collectCandidates(status: TailscaleStatus) -> [Candidate] {
|
||||
let selfDns = self.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: 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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import Foundation
|
||||
import OpenClawKit
|
||||
|
||||
struct WideAreaGatewayBeacon: Equatable {
|
||||
var instanceName: String
|
||||
var displayName: String
|
||||
var host: String
|
||||
var port: Int
|
||||
var lanHost: String?
|
||||
var tailnetDns: String?
|
||||
var gatewayPort: Int?
|
||||
var gatewayTls: Bool
|
||||
var gatewayDirectReachable: Bool
|
||||
var sshPort: Int?
|
||||
var cliPath: String?
|
||||
}
|
||||
|
||||
enum WideAreaGatewayDiscovery {
|
||||
private static let digPath = "/usr/bin/dig"
|
||||
private static let defaultTimeoutSeconds: TimeInterval = 0.2
|
||||
// Security: wide-area discovery must trust only the Tailscale MagicDNS resolver.
|
||||
// Probing arbitrary tailnet peers lets the fastest responder become DNS-SD authority.
|
||||
private static let tailscaleDNSResolver = "100.100.100.100"
|
||||
|
||||
struct DiscoveryContext {
|
||||
var tailscaleStatus: @Sendable () -> String?
|
||||
var dig: @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?
|
||||
|
||||
static let live = DiscoveryContext(
|
||||
tailscaleStatus: { readTailscaleStatus() },
|
||||
dig: { args, timeout in
|
||||
runDig(args: args, timeout: timeout)
|
||||
})
|
||||
}
|
||||
|
||||
static func discover(
|
||||
timeoutSeconds: TimeInterval = 2.0,
|
||||
context: DiscoveryContext = .live) -> [WideAreaGatewayBeacon]
|
||||
{
|
||||
let startedAt = Date()
|
||||
let remaining = {
|
||||
timeoutSeconds - Date().timeIntervalSince(startedAt)
|
||||
}
|
||||
|
||||
guard let statusJson = context.tailscaleStatus(),
|
||||
!collectTailnetIPv4s(statusJson: statusJson).isEmpty,
|
||||
let discovery = loadWideAreaPtrRecords(
|
||||
remaining: remaining,
|
||||
dig: context.dig)
|
||||
else { return [] }
|
||||
|
||||
let domainTrimmed = discovery.domainTrimmed
|
||||
let ptrLines = discovery.ptrLines
|
||||
let nameserver = self.tailscaleDNSResolver
|
||||
|
||||
var beacons: [WideAreaGatewayBeacon] = []
|
||||
for raw in ptrLines {
|
||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if ptr.isEmpty { continue }
|
||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
||||
let suffix = "._openclaw-gw._tcp.\(domainTrimmed)"
|
||||
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||
? String(ptrName.dropLast(suffix.count))
|
||||
: ptrName
|
||||
let instanceName = self.decodeDnsSdEscapes(rawInstanceName)
|
||||
|
||||
guard let srv = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "SRV"],
|
||||
min(defaultTimeoutSeconds, remaining()))
|
||||
else { continue }
|
||||
guard let (host, port) = parseSrv(srv) else { continue }
|
||||
|
||||
let txtRaw = context.dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", ptrName, "TXT"],
|
||||
min(self.defaultTimeoutSeconds, remaining()))
|
||||
let txtTokens = txtRaw.map(self.parseTxtTokens) ?? []
|
||||
let txt = self.mapTxt(tokens: txtTokens)
|
||||
|
||||
let displayName = txt["displayName"] ?? instanceName
|
||||
let beacon = WideAreaGatewayBeacon(
|
||||
instanceName: instanceName,
|
||||
displayName: displayName,
|
||||
host: host,
|
||||
port: port,
|
||||
lanHost: txt["lanHost"],
|
||||
tailnetDns: txt["tailnetDns"],
|
||||
gatewayPort: parseInt(txt["gatewayPort"]),
|
||||
gatewayTls: parseBool(txt["gatewayTls"]),
|
||||
gatewayDirectReachable: parseBool(txt["gatewayDirectReachable"]),
|
||||
sshPort: parseInt(txt["sshPort"]),
|
||||
cliPath: txt["cliPath"])
|
||||
beacons.append(beacon)
|
||||
}
|
||||
|
||||
return beacons
|
||||
}
|
||||
|
||||
private static func collectTailnetIPv4s(statusJson: String?) -> [String] {
|
||||
guard let statusJson else { return [] }
|
||||
let decoder = JSONDecoder()
|
||||
guard let data = statusJson.data(using: .utf8),
|
||||
let status = try? decoder.decode(TailscaleStatus.self, from: data)
|
||||
else { return [] }
|
||||
|
||||
var ips: [String] = []
|
||||
ips.append(contentsOf: status.selfNode?.resolvedIPs ?? [])
|
||||
if let peers = status.peer {
|
||||
for peer in peers.values {
|
||||
ips.append(contentsOf: peer.resolvedIPs)
|
||||
}
|
||||
}
|
||||
|
||||
var seen = Set<String>()
|
||||
return ips.filter { value in
|
||||
guard self.isTailnetIPv4(value) else { return false }
|
||||
if seen.contains(value) { return false }
|
||||
seen.insert(value)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private static func readTailscaleStatus() -> String? {
|
||||
let candidates = [
|
||||
"/usr/local/bin/tailscale",
|
||||
"/opt/homebrew/bin/tailscale",
|
||||
"/Applications/Tailscale.app/Contents/MacOS/Tailscale",
|
||||
"tailscale",
|
||||
]
|
||||
|
||||
var output: String?
|
||||
for candidate in candidates {
|
||||
if let result = run(
|
||||
path: candidate,
|
||||
args: ["status", "--json"],
|
||||
timeout: 0.7)
|
||||
{
|
||||
output = result
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return output
|
||||
}
|
||||
|
||||
private static func loadWideAreaPtrRecords(
|
||||
remaining: () -> TimeInterval,
|
||||
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?)
|
||||
-> (domainTrimmed: String, ptrLines: [Substring])?
|
||||
{
|
||||
guard let domain = OpenClawBonjour.wideAreaGatewayServiceDomain else { return nil }
|
||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||
let probeName = "_openclaw-gw._tcp.\(domainTrimmed)"
|
||||
let budget = max(0, remaining())
|
||||
if budget <= 0 { return nil }
|
||||
|
||||
guard let stdout = dig(
|
||||
["+short", "+time=1", "+tries=1", "@\(self.tailscaleDNSResolver)", probeName, "PTR"],
|
||||
min(defaultTimeoutSeconds, budget)),
|
||||
let ptrLines = stdout.split(whereSeparator: \.isNewline).nonEmpty
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return (domainTrimmed, ptrLines)
|
||||
}
|
||||
|
||||
private static func runDig(args: [String], timeout: TimeInterval) -> String? {
|
||||
self.run(path: self.digPath, args: args, timeout: timeout)
|
||||
}
|
||||
|
||||
private static func run(path: String, args: [String], timeout: TimeInterval) -> String? {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: path)
|
||||
process.arguments = args
|
||||
let outPipe = Pipe()
|
||||
process.standardOutput = outPipe
|
||||
// Avoid stderr pipe backpressure; we don't consume it.
|
||||
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 parseSrv(_ stdout: String) -> (String, Int)? {
|
||||
let line = stdout
|
||||
.split(whereSeparator: \.isNewline)
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.first(where: { !$0.isEmpty })
|
||||
guard let line else { return nil }
|
||||
let parts = line.split(whereSeparator: { $0 == " " || $0 == "\t" }).map(String.init)
|
||||
guard parts.count >= 4 else { return nil }
|
||||
guard let port = Int(parts[2]), port > 0 else { return nil }
|
||||
let host = parts[3].hasSuffix(".") ? String(parts[3].dropLast()) : parts[3]
|
||||
return (host, port)
|
||||
}
|
||||
|
||||
private static func parseTxtTokens(_ stdout: String) -> [String] {
|
||||
let lines = stdout.split(whereSeparator: \.isNewline)
|
||||
var tokens: [String] = []
|
||||
for raw in lines {
|
||||
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if line.isEmpty { continue }
|
||||
let matches = line.matches(of: /"([^"]*)"/)
|
||||
for match in matches {
|
||||
tokens.append(self.unescapeTxt(String(match.1)))
|
||||
}
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
private static func unescapeTxt(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\\\", with: "\\")
|
||||
.replacingOccurrences(of: "\\\"", with: "\"")
|
||||
.replacingOccurrences(of: "\\n", with: "\n")
|
||||
}
|
||||
|
||||
private static func mapTxt(tokens: [String]) -> [String: String] {
|
||||
var out: [String: String] = [:]
|
||||
for token in tokens {
|
||||
guard let idx = token.firstIndex(of: "=") else { continue }
|
||||
let key = String(token[..<idx]).trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let rawValue = String(token[token.index(after: idx)...])
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let value = self.decodeDnsSdEscapes(rawValue)
|
||||
if !key.isEmpty { out[key] = value }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private static func parseInt(_ value: String?) -> Int? {
|
||||
guard let value else { return nil }
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return Int(trimmed)
|
||||
}
|
||||
|
||||
private static func parseBool(_ value: String?) -> Bool {
|
||||
guard let value else { return false }
|
||||
let normalized = value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||
return normalized == "1" || normalized == "true" || normalized == "yes"
|
||||
}
|
||||
|
||||
private static func isTailnetIPv4(_ value: String) -> Bool {
|
||||
let parts = value.split(separator: ".")
|
||||
if parts.count != 4 { return false }
|
||||
let octets = parts.compactMap { Int($0) }
|
||||
if octets.count != 4 { return false }
|
||||
let a = octets[0]
|
||||
let b = octets[1]
|
||||
return a == 100 && b >= 64 && b <= 127
|
||||
}
|
||||
|
||||
private static func decodeDnsSdEscapes(_ value: String) -> String {
|
||||
var bytes: [UInt8] = []
|
||||
var pending = ""
|
||||
|
||||
func flushPending() {
|
||||
guard !pending.isEmpty else { return }
|
||||
bytes.append(contentsOf: pending.utf8)
|
||||
pending = ""
|
||||
}
|
||||
|
||||
let chars = Array(value)
|
||||
var i = 0
|
||||
while i < chars.count {
|
||||
let ch = chars[i]
|
||||
if ch == "\\", i + 3 < chars.count {
|
||||
let digits = String(chars[(i + 1)...(i + 3)])
|
||||
if digits.allSatisfy(\.isNumber),
|
||||
let byte = UInt8(digits)
|
||||
{
|
||||
flushPending()
|
||||
bytes.append(byte)
|
||||
i += 4
|
||||
continue
|
||||
}
|
||||
}
|
||||
pending.append(ch)
|
||||
i += 1
|
||||
}
|
||||
flushPending()
|
||||
|
||||
if bytes.isEmpty { return value }
|
||||
if let decoded = String(bytes: bytes, encoding: .utf8) {
|
||||
return decoded
|
||||
}
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
private struct TailscaleStatus: Decodable {
|
||||
struct Node: Decodable {
|
||||
let tailscaleIPs: [String]?
|
||||
|
||||
var resolvedIPs: [String] {
|
||||
self.tailscaleIPs ?? []
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case tailscaleIPs = "TailscaleIPs"
|
||||
}
|
||||
}
|
||||
|
||||
let selfNode: Node?
|
||||
let peer: [String: Node]?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case selfNode = "Self"
|
||||
case peer = "Peer"
|
||||
}
|
||||
}
|
||||
|
||||
extension Collection {
|
||||
fileprivate var nonEmpty: Self? {
|
||||
isEmpty ? nil : self
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user