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

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:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View 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])
}

View 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
}
}

View File

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

View File

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