import { existsSync } from "node:fs"; import { promptYesNo } from "../cli/prompt.js"; import { danger, info, logVerbose, shouldLogVerbose, warn } from "../globals.js"; import { runExec } from "../process/exec.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { colorize, isRich, theme } from "../terminal/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; import { ensureBinary } from "./binaries.js"; function parsePossiblyNoisyJsonObject(stdout: string): Record { const trimmed = stdout.trim(); const start = trimmed.indexOf("{"); const end = trimmed.lastIndexOf("}"); if (start >= 0 && end > start) { return JSON.parse(trimmed.slice(start, end + 1)) as Record; } return JSON.parse(trimmed) as Record; } /** * Locate Tailscale binary using multiple strategies: * 1. PATH lookup (via which command) * 2. Known macOS app path * 3. find /Applications for Tailscale.app * 4. locate database (if available) * * @returns Path to Tailscale binary or null if not found */ export async function findTailscaleBinary(): Promise { // Helper to check if a binary exists and is executable const checkBinary = async (path: string): Promise => { if (!path || !existsSync(path)) return false; try { // Use Promise.race with runExec to implement timeout await Promise.race([ runExec(path, ["--version"], { timeoutMs: 3000 }), new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 3000)), ]); return true; } catch { return false; } }; // Strategy 1: which command try { const { stdout } = await runExec("which", ["tailscale"]); const fromPath = stdout.trim(); if (fromPath && (await checkBinary(fromPath))) { return fromPath; } } catch { // which failed, continue } // Strategy 2: Known macOS app path const macAppPath = "/Applications/Tailscale.app/Contents/MacOS/Tailscale"; if (await checkBinary(macAppPath)) { return macAppPath; } // Strategy 3: find command in /Applications try { const { stdout } = await runExec( "find", [ "/Applications", "-maxdepth", "3", "-name", "Tailscale", "-path", "*/Tailscale.app/Contents/MacOS/Tailscale", ], { timeoutMs: 5000 }, ); const found = stdout.trim().split("\n")[0]; if (found && (await checkBinary(found))) { return found; } } catch { // find failed, continue } // Strategy 4: locate command try { const { stdout } = await runExec("locate", ["Tailscale.app"]); const candidates = stdout .trim() .split("\n") .filter((line) => line.includes("/Tailscale.app/Contents/MacOS/Tailscale")); for (const candidate of candidates) { if (await checkBinary(candidate)) { return candidate; } } } catch { // locate failed, continue } return null; } export async function getTailnetHostname(exec: typeof runExec = runExec, detectedBinary?: string) { // Derive tailnet hostname (or IP fallback) from tailscale status JSON. const candidates = detectedBinary ? [detectedBinary] : ["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"], { timeoutMs: 5000, maxBuffer: 400_000, }); const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; 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) ? ((parsed.Self as { TailscaleIPs?: string[] }).TailscaleIPs ?? []) : []; 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"); } /** * Get the Tailscale binary command to use. * Returns a cached detected binary or the default "tailscale" command. */ let cachedTailscaleBinary: string | null = null; export async function getTailscaleBinary(): Promise { if (cachedTailscaleBinary) return cachedTailscaleBinary; cachedTailscaleBinary = await findTailscaleBinary(); return cachedTailscaleBinary ?? "tailscale"; } export async function readTailscaleStatusJson( exec: typeof runExec = runExec, opts?: { timeoutMs?: number }, ): Promise> { const tailscaleBin = await getTailscaleBinary(); const { stdout } = await exec(tailscaleBin, ["status", "--json"], { timeoutMs: opts?.timeoutMs ?? 5000, maxBuffer: 400_000, }); return stdout ? parsePossiblyNoisyJsonObject(stdout) : {}; } export async function ensureGoInstalled( exec: typeof runExec = runExec, prompt: typeof promptYesNo = promptYesNo, runtime: RuntimeEnv = defaultRuntime, ) { // Ensure Go toolchain is present; offer Homebrew install if missing. const hasGo = await exec("go", ["version"]).then( () => true, () => false, ); if (hasGo) return; const install = await prompt( "Go is not installed. Install via Homebrew (brew install go)?", true, ); if (!install) { runtime.error("Go is required to build tailscaled from source. Aborting."); runtime.exit(1); } logVerbose("Installing Go via Homebrew…"); await exec("brew", ["install", "go"]); } export async function ensureTailscaledInstalled( exec: typeof runExec = runExec, prompt: typeof promptYesNo = promptYesNo, runtime: RuntimeEnv = defaultRuntime, ) { // Ensure tailscaled binary exists; install via Homebrew tailscale if missing. const hasTailscaled = await exec("tailscaled", ["--version"]).then( () => true, () => false, ); if (hasTailscaled) return; const install = await prompt( "tailscaled not found. Install via Homebrew (tailscale package)?", true, ); if (!install) { runtime.error("tailscaled is required for user-space funnel. Aborting."); runtime.exit(1); } logVerbose("Installing tailscaled via Homebrew…"); await exec("brew", ["install", "tailscale"]); } export async function ensureFunnel( port: number, exec: typeof runExec = runExec, runtime: RuntimeEnv = defaultRuntime, prompt: typeof promptYesNo = promptYesNo, ) { // Ensure Funnel is enabled and publish the webhook port. try { const tailscaleBin = await getTailscaleBinary(); const statusOut = (await exec(tailscaleBin, ["funnel", "status", "--json"])).stdout.trim(); const parsed = statusOut ? (JSON.parse(statusOut) as Record) : {}; if (!parsed || Object.keys(parsed).length === 0) { runtime.error(danger("Tailscale Funnel is not enabled on this tailnet/device.")); runtime.error( info( "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", ), ); runtime.error( info( "macOS user-space tailscaled docs: https://github.com/tailscale/tailscale/wiki/Tailscaled-on-macOS", ), ); const proceed = await prompt("Attempt local setup with user-space tailscaled?", true); if (!proceed) runtime.exit(1); await ensureBinary("brew", exec, runtime); await ensureGoInstalled(exec, prompt, runtime); await ensureTailscaledInstalled(exec, prompt, runtime); } logVerbose(`Enabling funnel on port ${port}…`); const { stdout } = await exec(tailscaleBin, ["funnel", "--yes", "--bg", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); if (stdout.trim()) console.log(stdout.trim()); } catch (err) { const errOutput = err as { stdout?: unknown; stderr?: unknown }; const stdout = typeof errOutput.stdout === "string" ? errOutput.stdout : ""; const stderr = typeof errOutput.stderr === "string" ? errOutput.stderr : ""; if (stdout.includes("Funnel is not enabled")) { console.error(danger("Funnel is not enabled on this tailnet/device.")); const linkMatch = stdout.match(/https?:\/\/\S+/); if (linkMatch) { console.error(info(`Enable it here: ${linkMatch[0]}`)); } else { console.error( info( "Enable in admin console: https://login.tailscale.com/admin (see https://tailscale.com/kb/1223/funnel)", ), ); } } if (stderr.includes("client version") || stdout.includes("client version")) { console.error( warn( "Tailscale client/server version mismatch detected; try updating tailscale/tailscaled.", ), ); } runtime.error("Failed to enable Tailscale Funnel. Is it allowed on your tailnet?"); runtime.error( info( `Tip: Funnel is optional for CLAWDBOT. You can keep running the web gateway without it: \`${formatCliCommand("clawdbot gateway")}\``, ), ); if (shouldLogVerbose()) { const rich = isRich(); if (stdout.trim()) { runtime.error(colorize(rich, theme.muted, `stdout: ${stdout.trim()}`)); } if (stderr.trim()) { runtime.error(colorize(rich, theme.muted, `stderr: ${stderr.trim()}`)); } runtime.error(err as Error); } runtime.exit(1); } } export async function enableTailscaleServe(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); await exec(tailscaleBin, ["serve", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); } export async function disableTailscaleServe(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); await exec(tailscaleBin, ["serve", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); } export async function enableTailscaleFunnel(port: number, exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); await exec(tailscaleBin, ["funnel", "--bg", "--yes", `${port}`], { maxBuffer: 200_000, timeoutMs: 15_000, }); } export async function disableTailscaleFunnel(exec: typeof runExec = runExec) { const tailscaleBin = await getTailscaleBinary(); await exec(tailscaleBin, ["funnel", "reset"], { maxBuffer: 200_000, timeoutMs: 15_000, }); }