From 778800be7014dee501dc6ca55ff5802c1a98e4de Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 21 Jan 2026 02:28:18 +0000 Subject: [PATCH] fix(macos): prefer tailnet ip for auto bind --- .../Clawdbot/GatewayEndpointStore.swift | 5 +- .../Sources/Clawdbot/TailscaleService.swift | 61 +++++++++++++++++-- .../ClawdbotMacCLI/ConnectCommand.swift | 58 +++++++++++++++++- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index dca3d48c3..87556fe59 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -556,7 +556,10 @@ actor GatewayEndpointStore { case "tailnet": tailscaleIP ?? "127.0.0.1" case "auto": - "127.0.0.1" + if let tailscaleIP, !tailscaleIP.isEmpty { + return tailscaleIP + } + return "127.0.0.1" case "custom": customBindHost ?? "127.0.0.1" default: diff --git a/apps/macos/Sources/Clawdbot/TailscaleService.swift b/apps/macos/Sources/Clawdbot/TailscaleService.swift index 53a3b7109..e3dc696b7 100644 --- a/apps/macos/Sources/Clawdbot/TailscaleService.swift +++ b/apps/macos/Sources/Clawdbot/TailscaleService.swift @@ -2,6 +2,9 @@ import AppKit import Foundation import Observation import os +#if canImport(Darwin) +import Darwin +#endif /// Manages Tailscale integration and status checking. @Observable @@ -101,15 +104,12 @@ final class TailscaleService { func checkTailscaleStatus() async { self.isInstalled = self.checkAppInstallation() - guard self.isInstalled else { + if !self.isInstalled { self.isRunning = false self.tailscaleHostname = nil self.tailscaleIP = nil self.statusError = "Tailscale is not installed" - return - } - - if let apiResponse = await fetchTailscaleStatus() { + } else if let apiResponse = await fetchTailscaleStatus() { self.isRunning = apiResponse.status.lowercased() == "running" if self.isRunning { @@ -138,6 +138,15 @@ final class TailscaleService { self.statusError = "Please start the Tailscale app" self.logger.info("Tailscale API not responding; app likely not running") } + + if self.tailscaleIP == nil, let fallback = Self.detectTailnetIPv4() { + self.tailscaleIP = fallback + if !self.isRunning { + self.isRunning = true + } + self.statusError = nil + self.logger.info("Tailscale interface IP detected (fallback) ip=\(fallback, privacy: .public)") + } } func openTailscaleApp() { @@ -163,4 +172,46 @@ final class TailscaleService { NSWorkspace.shared.open(url) } } + + private 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 + } + + private static func detectTailnetIPv4() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + if Self.isTailnetIPv4(ip) { return ip } + } + + return nil + } } diff --git a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift index 08e8cdde6..34ae71255 100644 --- a/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift +++ b/apps/macos/Sources/ClawdbotMacCLI/ConnectCommand.swift @@ -1,6 +1,9 @@ import ClawdbotKit import ClawdbotProtocol import Foundation +#if canImport(Darwin) +import Darwin +#endif struct ConnectOptions { var url: String? @@ -268,7 +271,7 @@ private func resolveGatewayEndpoint(opts: ConnectOptions, config: GatewayConfig) } let port = config.port ?? 18789 - let host = "127.0.0.1" + let host = resolveLocalHost(bind: config.bind) guard let url = URL(string: "ws://\(host):\(port)") else { throw NSError(domain: "Gateway", code: 1, userInfo: [NSLocalizedDescriptionKey: "invalid url: ws://\(host):\(port)"]) } @@ -304,3 +307,56 @@ private func resolvedPassword(opts: ConnectOptions, mode: String, config: Gatewa } return config.password } + +private func resolveLocalHost(bind: String?) -> String { + let normalized = (bind ?? "").trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + let tailnetIP = detectTailnetIPv4() + switch normalized { + case "tailnet", "auto": + return tailnetIP ?? "127.0.0.1" + default: + return "127.0.0.1" + } +} + +private func detectTailnetIPv4() -> String? { + var addrList: UnsafeMutablePointer? + guard getifaddrs(&addrList) == 0, let first = addrList else { return nil } + defer { freeifaddrs(addrList) } + + for ptr in sequence(first: first, next: { $0.pointee.ifa_next }) { + let flags = Int32(ptr.pointee.ifa_flags) + let isUp = (flags & IFF_UP) != 0 + let isLoopback = (flags & IFF_LOOPBACK) != 0 + let family = ptr.pointee.ifa_addr.pointee.sa_family + if !isUp || isLoopback || family != UInt8(AF_INET) { continue } + + var addr = ptr.pointee.ifa_addr.pointee + var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST)) + let result = getnameinfo( + &addr, + socklen_t(ptr.pointee.ifa_addr.pointee.sa_len), + &buffer, + socklen_t(buffer.count), + nil, + 0, + NI_NUMERICHOST) + guard result == 0 else { continue } + let len = buffer.prefix { $0 != 0 } + let bytes = len.map { UInt8(bitPattern: $0) } + guard let ip = String(bytes: bytes, encoding: .utf8) else { continue } + if isTailnetIPv4(ip) { return ip } + } + + return nil +} + +private 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 +}