From 3e39dd49aac57f5a795d584c032a111d41e79cac Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 20 Dec 2025 14:23:53 +0100 Subject: [PATCH] fix: auto-detect tailnet DNS hint --- .../Clawdis/GatewayDiscoveryMenu.swift | 12 ++++- docs/bonjour.md | 4 +- docs/discovery.md | 6 +-- src/gateway/server.ts | 23 +++++++--- src/infra/tailscale.ts | 46 +++++++++++++------ 5 files changed, 65 insertions(+), 26 deletions(-) diff --git a/apps/macos/Sources/Clawdis/GatewayDiscoveryMenu.swift b/apps/macos/Sources/Clawdis/GatewayDiscoveryMenu.swift index f486d413e..8c9cea583 100644 --- a/apps/macos/Sources/Clawdis/GatewayDiscoveryMenu.swift +++ b/apps/macos/Sources/Clawdis/GatewayDiscoveryMenu.swift @@ -86,7 +86,7 @@ struct GatewayDiscoveryInlineList: View { } private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? { - let host = gateway.tailnetDns ?? gateway.lanHost + let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost guard let host else { return nil } let user = NSUserName() var target = "\(user)@\(host)" @@ -96,6 +96,16 @@ struct GatewayDiscoveryInlineList: View { return target } + private func sanitizedTailnetHost(_ host: String?) -> String? { + guard let host else { return nil } + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { return nil } + if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") { + return nil + } + return trimmed + } + private func rowBackground(selected: Bool, hovered: Bool) -> Color { if selected { return Color.accentColor.opacity(0.12) } if hovered { return Color.secondary.opacity(0.08) } diff --git a/docs/bonjour.md b/docs/bonjour.md index 43b458c97..7c372d17d 100644 --- a/docs/bonjour.md +++ b/docs/bonjour.md @@ -93,7 +93,7 @@ The Gateway advertises small non-secret hints to make UI flows convenient: - `gatewayPort=` (informational; the Gateway WS is typically loopback-only) - `bridgePort=` (only when bridge is enabled) - `canvasPort=` (only when the canvas host is running; enabled by default; default `18793`) -- `tailnetDns=` (optional hint; may be absent) +- `tailnetDns=` (optional hint; auto-detected from Tailscale when available; may be absent) ## Debugging on macOS @@ -149,7 +149,7 @@ Bonjour/DNS-SD often escapes bytes in service instance names as decimal `\\DDD` - `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred). - `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set. - `CLAWDIS_SSH_PORT` overrides the SSH port advertised in `_clawdis-bridge._tcp`. -- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-bridge._tcp` (wide-area discovery uses `clawdis.internal.` automatically when enabled). +- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in `_clawdis-bridge._tcp`. If unset, the gateway auto-detects Tailscale and publishes the MagicDNS name when possible. ## Related docs diff --git a/docs/discovery.md b/docs/discovery.md index b1c00a872..4b15310c5 100644 --- a/docs/discovery.md +++ b/docs/discovery.md @@ -55,7 +55,7 @@ Troubleshooting and beacon details: `docs/bonjour.md`. - `gatewayPort=18789` (loopback WS port; informational) - `bridgePort=18790` (when bridge is enabled) - `canvasPort=18793` (when the canvas host is running; enabled by default) - - `tailnetDns=` (optional hint) +- `tailnetDns=` (optional hint; auto-detected when Tailscale is available) Disable/override: - `CLAWDIS_DISABLE_BONJOUR=1` disables advertising. @@ -63,14 +63,14 @@ Disable/override: - `bridge.bind` / `bridge.port` in `~/.clawdis/clawdis.json` control bridge bind/port (preferred). - `CLAWDIS_BRIDGE_HOST` / `CLAWDIS_BRIDGE_PORT` still work as a back-compat override when `bridge.bind` / `bridge.port` are not set. - `CLAWDIS_SSH_PORT` overrides the SSH port advertised in the bridge beacon (defaults to 22). -- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the bridge beacon. +- `CLAWDIS_TAILNET_DNS` publishes a `tailnetDns` hint (MagicDNS) in the bridge beacon (auto-detected if unset). ### 2) Tailnet (cross-network) For London/Vienna style setups, Bonjour won’t help. The recommended “direct” target is: - Tailscale MagicDNS name (preferred) or a stable tailnet IP. -If the gateway can detect it is running under Tailscale, it can publish `tailnetDns` as an optional hint for clients. +If the gateway can detect it is running under Tailscale, it publishes `tailnetDns` as an optional hint for clients. ### 3) Manual / SSH target diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 7d6898f41..31dec3b75 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -77,6 +77,7 @@ import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6, } from "../infra/tailnet.js"; +import { getTailnetHostname } from "../infra/tailscale.js"; import { defaultVoiceWakeTriggers, loadVoiceWakeConfig, @@ -89,6 +90,7 @@ import { import { logError, logInfo, logWarn } from "../logger.js"; import { getChildLogger, getResolvedLoggerSettings } from "../logging.js"; import { setCommandLaneConcurrency } from "../process/command-queue.js"; +import { runExec } from "../process/exec.js"; import { monitorWebProvider, webAuthExists } from "../providers/web/index.js"; import { defaultRuntime } from "../runtime.js"; import { monitorTelegramProvider } from "../telegram/monitor.js"; @@ -174,6 +176,20 @@ function formatBonjourInstanceName(displayName: string) { return `${trimmed} (Clawdis)`; } +async function resolveTailnetDnsHint(): Promise { + const envRaw = process.env.CLAWDIS_TAILNET_DNS?.trim(); + const env = envRaw && envRaw.length > 0 ? envRaw.replace(/\.$/, "") : ""; + if (env) return env; + + const exec: typeof runExec = (command, args) => + runExec(command, args, { timeoutMs: 1500, maxBuffer: 200_000 }); + try { + return await getTailnetHostname(exec); + } catch { + return undefined; + } +} + type GatewaySessionsDefaults = { model: string | null; contextTokens: number | null; @@ -2048,12 +2064,7 @@ export async function startGatewayServer( ? sshPortParsed : undefined; - const tailnetDnsEnv = process.env.CLAWDIS_TAILNET_DNS?.trim(); - const tailnetDns = wideAreaDiscoveryEnabled - ? WIDE_AREA_DISCOVERY_DOMAIN - : tailnetDnsEnv && tailnetDnsEnv.length > 0 - ? tailnetDnsEnv - : undefined; + const tailnetDns = await resolveTailnetDnsHint(); const bonjour = await startGatewayBonjourAdvertiser({ instanceName: formatBonjourInstanceName(machineDisplayName), diff --git a/src/infra/tailscale.ts b/src/infra/tailscale.ts index 7500deba1..615eb78ca 100644 --- a/src/infra/tailscale.ts +++ b/src/infra/tailscale.ts @@ -1,3 +1,4 @@ +import { existsSync } from "node:fs"; import chalk from "chalk"; import { promptYesNo } from "../cli/prompt.js"; import { danger, info, isVerbose, logVerbose, warn } from "../globals.js"; @@ -7,20 +8,37 @@ import { ensureBinary } from "./binaries.js"; export async function getTailnetHostname(exec: typeof runExec = runExec) { // Derive tailnet hostname (or IP fallback) from tailscale status JSON. - const { stdout } = await exec("tailscale", ["status", "--json"]); - const parsed = stdout ? (JSON.parse(stdout) as Record) : {}; - const self = - typeof parsed.Self === "object" && parsed.Self !== null - ? (parsed.Self as Record) - : undefined; - const dns = - typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined; - const ips = Array.isArray(self?.TailscaleIPs) - ? (self.TailscaleIPs as string[]) - : []; - if (dns && dns.length > 0) return dns.replace(/\.$/, ""); - if (ips.length > 0) return ips[0]; - throw new Error("Could not determine Tailscale DNS or IP"); + const candidates = [ + "tailscale", + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + ]; + let lastError: unknown; + + for (const candidate of candidates) { + if (candidate.startsWith("/") && !existsSync(candidate)) continue; + try { + const { stdout } = await exec(candidate, ["status", "--json"]); + const parsed = stdout + ? (JSON.parse(stdout) as Record) + : {}; + const self = + typeof parsed.Self === "object" && parsed.Self !== null + ? (parsed.Self as Record) + : undefined; + const dns = + typeof self?.DNSName === "string" ? (self.DNSName as string) : undefined; + const ips = Array.isArray(self?.TailscaleIPs) + ? (self.TailscaleIPs as string[]) + : []; + if (dns && dns.length > 0) return dns.replace(/\.$/, ""); + if (ips.length > 0) return ips[0]; + throw new Error("Could not determine Tailscale DNS or IP"); + } catch (err) { + lastError = err; + } + } + + throw lastError ?? new Error("Could not determine Tailscale DNS or IP"); } export async function ensureGoInstalled(