fix: auto-detect tailnet DNS hint

This commit is contained in:
Peter Steinberger
2025-12-20 14:23:53 +01:00
parent 082b4fb193
commit 3e39dd49aa
5 changed files with 65 additions and 26 deletions

View File

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

View File

@@ -93,7 +93,7 @@ The Gateway advertises small non-secret hints to make UI flows convenient:
- `gatewayPort=<port>` (informational; the Gateway WS is typically loopback-only)
- `bridgePort=<port>` (only when bridge is enabled)
- `canvasPort=<port>` (only when the canvas host is running; enabled by default; default `18793`)
- `tailnetDns=<magicdns>` (optional hint; may be absent)
- `tailnetDns=<magicdns>` (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

View File

@@ -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=<magicdns>` (optional hint)
- `tailnetDns=<magicdns>` (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 wont 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

View File

@@ -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<string | undefined> {
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),

View File

@@ -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<string, unknown>) : {};
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: 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<string, unknown>)
: {};
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: 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(