import { execFile, spawn } from "child_process"; import fs from "fs"; import fsPromises from "fs/promises"; import os from "os"; import path from "path"; import { promisify } from "util"; import { getSettings, updateSettings } from "@/lib/db/settings"; import { resolveDataDir } from "@/lib/dataPaths"; import { getRuntimePorts } from "@/lib/runtime/ports"; import { getCachedPassword, setCachedPassword } from "@/mitm/manager"; import { execFileWithPassword } from "@/mitm/systemCommands"; import { getConsistentMachineId } from "@/shared/utils/machineId"; const execFileAsync = promisify(execFile); const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; const WINDOWS_TAILSCALED_BIN = "C:\\Program Files\\Tailscale\\tailscaled.exe"; const IS_MAC = process.platform === "darwin"; const IS_LINUX = process.platform === "linux"; const IS_WINDOWS = process.platform === "win32"; const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:${process.env.PATH || ""}`; const LOGIN_TIMEOUT_MS = 15000; const FUNNEL_TIMEOUT_MS = 30000; // System-level tailscaled socket (used by apt/brew installed tailscale) const SYSTEM_SOCKET_LINUX = "/var/run/tailscale/tailscaled.sock"; const SYSTEM_SOCKET_MAC = "/var/run/tailscaled.sock"; /** Cached active socket path — avoids repeated probing during a single request */ let _cachedActiveSocket: string | null = null; let _cachedActiveSocketTimestamp = 0; const SOCKET_CACHE_TTL_MS = 10_000; type JsonRecord = Record; export type TailscaleTunnelInstallSource = "managed" | "path" | "env" | "windows-default"; export type TailscaleTunnelPhase = | "unsupported" | "not_installed" | "needs_login" | "stopped" | "running" | "error"; type PersistedTailscaleState = { binaryPath?: string | null; installSource?: TailscaleTunnelInstallSource | null; daemonPid?: number | null; tunnelUrl?: string | null; lastError?: string | null; installedAt?: string | null; updatedAt?: string | null; }; type BinaryResolution = { binaryPath: string | null; installSource: TailscaleTunnelInstallSource | null; managedInstall: boolean; }; type TailscaleLoginResult = { alreadyLoggedIn: true } | { authUrl: string }; type TailscaleFunnelResult = | { tunnelUrl: string } | { funnelNotEnabled: true; enableUrl: string | null }; export type TailscaleCheckStatus = { supported: boolean; installed: boolean; managedInstall: boolean; installSource: TailscaleTunnelInstallSource | null; binaryPath: string | null; loggedIn: boolean; daemonRunning: boolean; running: boolean; tunnelUrl: string | null; apiUrl: string | null; platform: NodeJS.Platform; brewAvailable: boolean; lastError: string | null; pid: number | null; }; export type TailscaleTunnelStatus = TailscaleCheckStatus & { enabled: boolean; phase: TailscaleTunnelPhase; }; export type TailscaleEnableResult = | { success: true; tunnelUrl: string; apiUrl: string | null; status: TailscaleTunnelStatus; } | { success: false; needsLogin: true; authUrl: string; status: TailscaleTunnelStatus; } | { success: false; funnelNotEnabled: true; enableUrl: string | null; status: TailscaleTunnelStatus; }; function asRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } function toNonEmptyString(value: unknown): string | null { if (typeof value !== "string") return null; const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } function sleep(ms: number) { return new Promise((resolve) => setTimeout(resolve, ms)); } function shellEscape(value: string) { return `'${value.replace(/'/g, `'\"'\"'`)}'`; } function isSupportedPlatform(platform = process.platform) { return platform === "darwin" || platform === "linux" || platform === "win32"; } function getTailscaleDir() { return path.join(resolveDataDir(), "tailscale"); } function getManagedBinaryPath(platform = process.platform) { return path.join(getTailscaleDir(), "bin", platform === "win32" ? "tailscale.exe" : "tailscale"); } function getStateFilePath() { return path.join(getTailscaleDir(), "state.json"); } function getPidFilePath() { return path.join(getTailscaleDir(), ".tailscaled.pid"); } function getLogFilePath() { return path.join(getTailscaleDir(), "tailscaled.log"); } export function getTailscaleSocketPath() { return path.join(getTailscaleDir(), "tailscaled.sock"); } async function ensureTailscaleDir() { await fsPromises.mkdir(path.join(getTailscaleDir(), "bin"), { recursive: true }); } async function readStateFile(): Promise { try { const raw = await fsPromises.readFile(getStateFilePath(), "utf8"); return JSON.parse(raw) as PersistedTailscaleState; } catch { return {}; } } async function writeStateFile(state: PersistedTailscaleState) { await ensureTailscaleDir(); await fsPromises.writeFile(getStateFilePath(), JSON.stringify(state, null, 2) + "\n", "utf8"); } async function updateStateFile(patch: Partial) { const current = await readStateFile(); await writeStateFile({ ...current, ...patch, updatedAt: new Date().toISOString(), }); } async function readPidFile() { try { const raw = await fsPromises.readFile(getPidFilePath(), "utf8"); const pid = Number.parseInt(raw.trim(), 10); return Number.isFinite(pid) ? pid : null; } catch { return null; } } async function clearPidFile() { try { await fsPromises.unlink(getPidFilePath()); } catch { // Ignore stale or missing pid files. } } function isProcessAlive(pid: number | null) { if (!pid || pid <= 0) return false; try { process.kill(pid, 0); return true; } catch { return false; } } function getTailscaleApiUrl(tunnelUrl: string | null) { return tunnelUrl ? `${tunnelUrl.replace(/\/$/, "")}/v1` : null; } async function resolvePathCommand(command: string) { const lookupCommand = process.platform === "win32" ? "where" : "which"; try { const { stdout } = await execFileAsync(lookupCommand, [command], { timeout: 3000, windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH, }, }); const first = stdout .split(/\r?\n/) .map((line) => line.trim()) .find(Boolean); return first || null; } catch { return null; } } async function resolveBinary(): Promise { const envPath = toNonEmptyString(process.env.TAILSCALE_BIN); if (envPath && fs.existsSync(envPath)) { return { binaryPath: envPath, installSource: "env", managedInstall: false }; } const managedPath = getManagedBinaryPath(); if (fs.existsSync(managedPath)) { return { binaryPath: managedPath, installSource: "managed", managedInstall: true }; } const pathBinary = await resolvePathCommand("tailscale"); if (pathBinary) { return { binaryPath: pathBinary, installSource: "path", managedInstall: false }; } if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) { return { binaryPath: WINDOWS_TAILSCALE_BIN, installSource: "windows-default", managedInstall: false, }; } return { binaryPath: null, installSource: null, managedInstall: false }; } async function resolveDaemonBinary(tailscaleBinaryPath: string | null) { const envPath = toNonEmptyString(process.env.TAILSCALED_BIN); if (envPath && fs.existsSync(envPath)) return envPath; const sibling = tailscaleBinaryPath ? path.join( path.dirname(tailscaleBinaryPath), process.platform === "win32" ? "tailscaled.exe" : "tailscaled" ) : null; if (sibling && fs.existsSync(sibling)) return sibling; const pathBinary = await resolvePathCommand("tailscaled"); if (pathBinary) return pathBinary; if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALED_BIN)) return WINDOWS_TAILSCALED_BIN; return null; } function buildExecEnv() { return { ...process.env, PATH: EXTENDED_PATH, }; } /** * Probe which tailscaled socket is actually live. * Priority: system daemon socket → OmniRoute custom socket. * When the system daemon is running (e.g. via systemd), we MUST use its socket * because only one tailscaled can hold the TUN device. */ async function getActiveSocketPath(): Promise { const now = Date.now(); if (_cachedActiveSocket && now - _cachedActiveSocketTimestamp < SOCKET_CACHE_TTL_MS) { return _cachedActiveSocket; } // Check system sockets first const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null; if (systemSocket && fs.existsSync(systemSocket)) { _cachedActiveSocket = systemSocket; _cachedActiveSocketTimestamp = now; return systemSocket; } // Fallback to OmniRoute custom socket const customSocket = getTailscaleSocketPath(); _cachedActiveSocket = customSocket; _cachedActiveSocketTimestamp = now; return customSocket; } /** Synchronous check: is the system daemon socket available? */ function isSystemDaemonAvailable(): boolean { const systemSocket = IS_LINUX ? SYSTEM_SOCKET_LINUX : IS_MAC ? SYSTEM_SOCKET_MAC : null; return Boolean(systemSocket && fs.existsSync(systemSocket)); } /** Invalidate socket cache so the next call re-probes */ function invalidateSocketCache() { _cachedActiveSocket = null; _cachedActiveSocketTimestamp = 0; } async function buildTailscaleArgs(...args: string[]) { if (IS_WINDOWS) return args; const socket = await getActiveSocketPath(); return ["--socket", socket, ...args]; } /** Synchronous variant for places that cannot await */ function buildTailscaleArgsSync(...args: string[]) { if (IS_WINDOWS) return args; // Use cached socket or default to system socket if available const socket = _cachedActiveSocket || (isSystemDaemonAvailable() ? IS_LINUX ? SYSTEM_SOCKET_LINUX : SYSTEM_SOCKET_MAC : getTailscaleSocketPath()); return ["--socket", socket!, ...args]; } async function readJsonCommand(binaryPath: string, args: string[], timeout = 5000) { try { const { stdout } = await execFileAsync(binaryPath, args, { timeout, windowsHide: true, env: buildExecEnv(), }); return JSON.parse(stdout) as JsonRecord; } catch { return null; } } async function getLiveStatusPayload(binaryPath: string | null) { if (!binaryPath) return null; return readJsonCommand(binaryPath, await buildTailscaleArgs("status", "--json")); } async function getLiveFunnelPayload(binaryPath: string | null) { if (!binaryPath) return null; return readJsonCommand(binaryPath, await buildTailscaleArgs("funnel", "status", "--json")); } function isBackendRunning(payload: unknown) { return toNonEmptyString(asRecord(payload).BackendState) === "Running"; } function isFunnelRunning(payload: unknown) { const allowFunnel = asRecord(payload).AllowFunnel; return Boolean( allowFunnel && typeof allowFunnel === "object" && Object.keys(allowFunnel).length > 0 ); } export function getTailscaleUrlFromStatusPayload(payload: unknown) { const self = asRecord(asRecord(payload).Self); const dnsName = toNonEmptyString(self.DNSName); if (!dnsName) return null; const normalized = dnsName.replace(/\.$/, ""); return normalized ? `https://${normalized}` : null; } export function extractTailscaleAuthUrl(text: string) { const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9-]+/); return match ? match[0] : null; } export function extractTailscaleEnableUrl(text: string) { const match = text.match(/https:\/\/login\.tailscale\.com\/[^\s"']+/); return match ? match[0] : null; } export function extractTailscaleFunnelUrl(text: string) { const match = text.match(/https:\/\/[a-z0-9-]+\.[a-z0-9.-]+\.ts\.net\b[^\s"']*/i); if (!match) return null; return match[0].replace(/\/$/, ""); } async function getDefaultHostname() { try { const machineId = await getConsistentMachineId(); const normalized = `omniroute-${machineId.slice(0, 8)}`.replace(/[^a-zA-Z0-9-]/g, "-"); return normalized.toLowerCase(); } catch { const hostname = os .hostname() .replace(/[^a-zA-Z0-9-]/g, "-") .toLowerCase(); return hostname || "omniroute"; } } function getLastError(state: PersistedTailscaleState) { return typeof state.lastError === "string" && state.lastError.trim() ? state.lastError : null; } async function hasBrew() { if (!IS_MAC) return false; try { await execFileAsync("which", ["brew"], { timeout: 3000, windowsHide: true, env: buildExecEnv(), }); return true; } catch { return false; } } async function getLiveTunnelUrl(binaryPath: string | null) { const payload = await getLiveStatusPayload(binaryPath); return getTailscaleUrlFromStatusPayload(payload); } export async function getTailscaleCheckStatus(): Promise { const resolution = await resolveBinary(); const [state, statusPayload, funnelPayload, brewAvailable] = await Promise.all([ readStateFile(), getLiveStatusPayload(resolution.binaryPath), getLiveFunnelPayload(resolution.binaryPath), hasBrew(), ]); const liveTunnelUrl = getTailscaleUrlFromStatusPayload(statusPayload); const storedTunnelUrl = toNonEmptyString(state.tunnelUrl); const tunnelUrl = liveTunnelUrl || storedTunnelUrl; const loggedIn = isBackendRunning(statusPayload); const daemonRunning = Boolean(statusPayload) || isProcessAlive((await readPidFile()) || null); return { supported: isSupportedPlatform(), installed: Boolean(resolution.binaryPath), managedInstall: resolution.managedInstall, installSource: resolution.installSource, binaryPath: resolution.binaryPath, loggedIn, daemonRunning, running: isFunnelRunning(funnelPayload), tunnelUrl, apiUrl: getTailscaleApiUrl(tunnelUrl), platform: process.platform, brewAvailable, lastError: getLastError(state), pid: await readPidFile(), }; } export async function getTailscaleTunnelStatus(): Promise { const [check, settings] = await Promise.all([getTailscaleCheckStatus(), getSettings()]); const storedSettingUrl = typeof settings.tailscaleUrl === "string" && settings.tailscaleUrl.trim() ? settings.tailscaleUrl : null; const tunnelUrl = check.tunnelUrl || storedSettingUrl; const running = check.loggedIn && check.running && Boolean(tunnelUrl); const enabled = settings.tailscaleEnabled === true && running; let phase: TailscaleTunnelPhase = "stopped"; if (!check.supported) phase = "unsupported"; else if (!check.installed) phase = "not_installed"; else if (running) phase = "running"; else if (check.daemonRunning && !check.loggedIn) phase = "needs_login"; else if (check.lastError) phase = "error"; return { ...check, running, enabled, tunnelUrl, apiUrl: getTailscaleApiUrl(tunnelUrl), phase, }; } async function runSudoShell(command: string, password: string) { const normalizedPassword = password.trim(); if (!normalizedPassword) { throw new Error("Sudo password required"); } await execFileWithPassword("sudo", ["-S", "sh", "-c", command], normalizedPassword); } export async function startTailscaleDaemon({ sudoPassword, }: { sudoPassword?: string; } = {}) { const resolution = await resolveBinary(); if (!resolution.binaryPath) { throw new Error("Tailscale is not installed"); } // Invalidate socket cache so we re-probe the system socket invalidateSocketCache(); // Check if the system daemon is already running (e.g. via systemd) // This is the most common case on servers where tailscale was installed via apt/brew if (isSystemDaemonAvailable()) { const systemStatus = await getLiveStatusPayload(resolution.binaryPath); if (systemStatus) { // System daemon is live — no need to start our own return { started: false, systemDaemon: true }; } } // Check if our custom daemon is already running const existingStatus = await getLiveStatusPayload(resolution.binaryPath); if (existingStatus) { return { started: false }; } if (IS_WINDOWS) { try { await execFileAsync("net", ["start", "Tailscale"], { timeout: 10000, windowsHide: true, env: buildExecEnv(), }); } catch { // Ignore service start errors and verify below. } await sleep(2500); if (!(await getLiveStatusPayload(resolution.binaryPath))) { throw new Error("Failed to start Tailscale service"); } return { started: true }; } const daemonBinary = await resolveDaemonBinary(resolution.binaryPath); if (!daemonBinary) { throw new Error("tailscaled binary not found"); } const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; if (!password) { throw new Error("Sudo password required to start tailscaled"); } setCachedPassword(password); await ensureTailscaleDir(); const command = [ `mkdir -p ${shellEscape(getTailscaleDir())}`, `nohup ${shellEscape(daemonBinary)} --socket=${shellEscape(getTailscaleSocketPath())} --statedir=${shellEscape(getTailscaleDir())} >> ${shellEscape(getLogFilePath())} 2>&1 & echo $! > ${shellEscape(getPidFilePath())}`, ].join(" && "); await runSudoShell(command, password); await sleep(3000); // Re-probe socket after starting invalidateSocketCache(); if (!(await getLiveStatusPayload(resolution.binaryPath))) { throw new Error("tailscaled did not become ready"); } const pid = await readPidFile(); await updateStateFile({ binaryPath: resolution.binaryPath, installSource: resolution.installSource, daemonPid: pid, lastError: null, }); return { started: true }; } export async function startTailscaleLogin({ hostname, }: { hostname?: string; } = {}): Promise { const resolution = await resolveBinary(); if (!resolution.binaryPath) { throw new Error("Tailscale is not installed"); } const currentStatus = await getLiveStatusPayload(resolution.binaryPath); if (isBackendRunning(currentStatus)) { return { alreadyLoggedIn: true }; } const resolvedHostname = toNonEmptyString(hostname) || (await getDefaultHostname()); const spawnArgs = await buildTailscaleArgs( "up", "--accept-routes", ...(resolvedHostname ? [`--hostname=${resolvedHostname}`] : []) ); return new Promise((resolve, reject) => { const child = spawn(resolution.binaryPath as string, spawnArgs, { detached: true, windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildExecEnv(), }); let settled = false; let output = ""; const settle = (callback: () => void) => { if (settled) return; settled = true; clearTimeout(timeoutId); callback(); }; const handleData = (chunk: Buffer | string) => { output += chunk.toString(); const authUrl = extractTailscaleAuthUrl(output); if (authUrl) { settle(() => resolve({ authUrl })); } }; const timeoutId = setTimeout(() => { const authUrl = extractTailscaleAuthUrl(output); if (authUrl) { settle(() => resolve({ authUrl })); return; } settle(() => reject(new Error("Tailscale login timed out"))); }, LOGIN_TIMEOUT_MS); child.stdout.on("data", handleData); child.stderr.on("data", handleData); child.on("error", (error) => settle(() => reject(error))); child.on("close", async (code) => { if (settled) return; const authUrl = extractTailscaleAuthUrl(output); if (authUrl) { settle(() => resolve({ authUrl })); return; } const latestStatus = await getLiveStatusPayload(resolution.binaryPath); if (code === 0 || isBackendRunning(latestStatus)) { settle(() => resolve({ alreadyLoggedIn: true })); return; } settle(() => reject(new Error(`tailscale up exited with code ${code ?? "unknown"}`))); }); child.unref(); }); } async function resetTailscaleFunnel(binaryPath: string) { try { await execFileAsync(binaryPath, await buildTailscaleArgs("funnel", "--bg", "reset"), { timeout: 5000, windowsHide: true, env: buildExecEnv(), }); } catch { // Ignore stale or missing funnel state. } } export async function startTailscaleFunnel( port = getRuntimePorts().apiPort ): Promise { const resolution = await resolveBinary(); if (!resolution.binaryPath) { throw new Error("Tailscale is not installed"); } await resetTailscaleFunnel(resolution.binaryPath); const funnelArgs = await buildTailscaleArgs("funnel", "--bg", String(port)); return new Promise((resolve, reject) => { const child = spawn(resolution.binaryPath as string, funnelArgs, { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildExecEnv(), }); let settled = false; let output = ""; const settle = (callback: () => void) => { if (settled) return; settled = true; clearTimeout(timeoutId); callback(); }; const finalizeFromOutput = async () => { const url = extractTailscaleFunnelUrl(output) || (await getLiveTunnelUrl(resolution.binaryPath)); if (url) { settle(() => resolve({ tunnelUrl: url })); return; } const enableUrl = extractTailscaleEnableUrl(output); if (/funnel is not enabled/i.test(output) || enableUrl) { settle(() => resolve({ funnelNotEnabled: true, enableUrl })); return; } settle(() => reject(new Error(output.trim() || "Failed to start Tailscale Funnel"))); }; const handleData = (chunk: Buffer | string) => { output += chunk.toString(); const tunnelUrl = extractTailscaleFunnelUrl(output); if (tunnelUrl) { settle(() => resolve({ tunnelUrl })); return; } const enableUrl = extractTailscaleEnableUrl(output); if (/funnel is not enabled/i.test(output) && enableUrl) { settle(() => resolve({ funnelNotEnabled: true, enableUrl })); } }; const timeoutId = setTimeout(() => { void finalizeFromOutput(); }, FUNNEL_TIMEOUT_MS); child.stdout.on("data", handleData); child.stderr.on("data", handleData); child.on("error", (error) => settle(() => reject(error))); child.on("close", () => { void finalizeFromOutput(); }); }); } export async function stopTailscaleFunnel() { const resolution = await resolveBinary(); if (!resolution.binaryPath) return; await resetTailscaleFunnel(resolution.binaryPath); } export async function stopTailscaleDaemon({ sudoPassword, }: { sudoPassword?: string; } = {}) { const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; const pid = await readPidFile(); if (pid && isProcessAlive(pid)) { try { process.kill(pid, "SIGTERM"); } catch { // Ignore non-owned or stale processes. } } await sleep(1000); if (pid && isProcessAlive(pid) && password) { try { await runSudoShell(`kill ${Number(pid)}`, password); } catch { // Ignore fallback failures and keep trying generic process matches. } } if (!IS_WINDOWS) { try { await execFileAsync("pkill", ["-x", "tailscaled"], { timeout: 3000, windowsHide: true, env: buildExecEnv(), }); } catch { // Ignore when the daemon is not running or not owned by this user. } if (password) { try { await runSudoShell("pkill -x tailscaled", password); } catch { // Ignore final privileged shutdown failures. } } } else { try { await execFileAsync("net", ["stop", "Tailscale"], { timeout: 10000, windowsHide: true, env: buildExecEnv(), }); } catch { // Ignore service stop failures on Windows. } } await clearPidFile(); try { await fsPromises.unlink(getTailscaleSocketPath()); } catch { // Ignore missing sockets. } } export async function enableTailscaleTunnel({ sudoPassword, hostname, port, }: { sudoPassword?: string; hostname?: string; port?: number; } = {}): Promise { const normalizedPassword = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; if (normalizedPassword) { setCachedPassword(normalizedPassword); } const targetPort = port || getRuntimePorts().apiPort; try { await startTailscaleDaemon({ sudoPassword: normalizedPassword }); const resolution = await resolveBinary(); const currentStatus = await getLiveStatusPayload(resolution.binaryPath); if (!isBackendRunning(currentStatus)) { const loginResult = await startTailscaleLogin({ hostname }); if ("authUrl" in loginResult) { await updateStateFile({ lastError: null }); return { success: false, needsLogin: true, authUrl: loginResult.authUrl, status: await getTailscaleTunnelStatus(), }; } } const funnelResult = await startTailscaleFunnel(targetPort); if ("funnelNotEnabled" in funnelResult) { await updateStateFile({ lastError: null }); return { success: false, funnelNotEnabled: true, enableUrl: funnelResult.enableUrl, status: await getTailscaleTunnelStatus(), }; } const tunnelUrl = funnelResult.tunnelUrl || (await getLiveTunnelUrl((await resolveBinary()).binaryPath)); if (!tunnelUrl) { throw new Error("Failed to determine the Tailscale Funnel URL"); } await updateSettings({ tailscaleEnabled: true, tailscaleUrl: tunnelUrl, }); await updateStateFile({ tunnelUrl, lastError: null, }); return { success: true, tunnelUrl, apiUrl: getTailscaleApiUrl(tunnelUrl), status: await getTailscaleTunnelStatus(), }; } catch (error) { const message = error instanceof Error ? error.message : "Failed to enable Tailscale Funnel"; await updateStateFile({ lastError: message }); throw error; } } export async function disableTailscaleTunnel({ sudoPassword, }: { sudoPassword?: string; } = {}) { const normalizedPassword = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; if (normalizedPassword) { setCachedPassword(normalizedPassword); } try { await stopTailscaleFunnel(); await stopTailscaleDaemon({ sudoPassword: normalizedPassword }); await updateSettings({ tailscaleEnabled: false, tailscaleUrl: "", }); await updateStateFile({ tunnelUrl: null, lastError: null, }); return { success: true, status: await getTailscaleTunnelStatus(), }; } catch (error) { const message = error instanceof Error ? error.message : "Failed to disable Tailscale Funnel"; await updateStateFile({ lastError: message }); throw error; } } function createStreamLogger(onProgress: ((message: string) => void) | undefined) { return (chunk: Buffer | string) => { const text = chunk.toString(); const lines = text .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean); for (const line of lines) { onProgress?.(line); } }; } async function installTailscaleMac(password: string, onProgress?: (message: string) => void) { if (await hasBrew()) { await new Promise((resolve, reject) => { const child = spawn("brew", ["install", "tailscale"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildExecEnv(), }); const log = createStreamLogger(onProgress); child.stdout.on("data", log); child.stderr.on("data", log); child.on("close", (code) => { if (code === 0) resolve(); else reject(new Error(`brew install failed with code ${code}`)); }); child.on("error", reject); }); return; } if (!password.trim()) { throw new Error("Sudo password required to install Tailscale"); } const pkgUrl = "https://pkgs.tailscale.com/stable/tailscale-latest.pkg"; const pkgPath = path.join(os.tmpdir(), "tailscale.pkg"); await new Promise((resolve, reject) => { const child = spawn("curl", ["-fL", "--progress-bar", pkgUrl, "-o", pkgPath], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildExecEnv(), }); child.stderr.on("data", createStreamLogger(onProgress)); child.on("close", (code) => { if (code === 0) resolve(); else reject(new Error("Failed to download the Tailscale package")); }); child.on("error", reject); }); await new Promise((resolve, reject) => { const child = spawn("sudo", ["-S", "installer", "-pkg", pkgPath, "-target", "/"], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: buildExecEnv(), }); const log = createStreamLogger(onProgress); let stderr = ""; child.stdin.write(`${password}\n`); child.stdin.end(); child.stdout.on("data", log); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); log(chunk); }); child.on("close", async (code) => { try { await fsPromises.unlink(pkgPath); } catch { // Ignore cleanup errors. } if (code === 0) resolve(); else reject(new Error(stderr.trim() || `installer exited with code ${code}`)); }); child.on("error", reject); }); } async function installTailscaleLinux(password: string, onProgress?: (message: string) => void) { if (!password.trim()) { throw new Error("Sudo password required to install Tailscale"); } await new Promise((resolve, reject) => { const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildExecEnv(), }); let scriptContent = ""; let downloadError = ""; curlChild.stdout.on("data", (chunk) => { scriptContent += chunk.toString(); }); curlChild.stderr.on("data", (chunk) => { downloadError += chunk.toString(); createStreamLogger(onProgress)(chunk); }); curlChild.on("close", (code) => { if (code !== 0) { reject(new Error(downloadError.trim() || "Failed to download the Tailscale installer")); return; } const child = spawn("sudo", ["-S", "sh"], { windowsHide: true, stdio: ["pipe", "pipe", "pipe"], env: buildExecEnv(), }); let stderr = ""; const log = createStreamLogger(onProgress); child.stdin.write(`${password}\n`); child.stdin.write(scriptContent); child.stdin.end(); child.stdout.on("data", log); child.stderr.on("data", (chunk) => { stderr += chunk.toString(); log(chunk); }); child.on("close", (installCode) => { if (installCode === 0) resolve(); else reject(new Error(stderr.trim() || `install.sh exited with code ${installCode}`)); }); child.on("error", reject); }); curlChild.on("error", reject); }); } async function installTailscaleWindows(onProgress?: (message: string) => void) { const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"; const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi"); await new Promise((resolve, reject) => { const child = spawn("curl.exe", ["-L", "-#", "-o", msiPath, msiUrl], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildExecEnv(), }); child.stderr.on("data", createStreamLogger(onProgress)); child.on("close", (code) => { if (code === 0) resolve(); else reject(new Error("Failed to download the Tailscale installer")); }); child.on("error", reject); }); await new Promise((resolve, reject) => { const child = spawn( "powershell", [ "-NoProfile", "-NonInteractive", "-Command", `Start-Process msiexec -ArgumentList '/i','${msiPath}','TS_NOLAUNCH=true','/quiet','/norestart' -Verb RunAs -Wait`, ], { windowsHide: true, stdio: ["ignore", "pipe", "pipe"], env: buildExecEnv(), } ); const log = createStreamLogger(onProgress); child.stdout.on("data", log); child.stderr.on("data", log); child.on("close", async (code) => { try { await fsPromises.unlink(msiPath); } catch { // Ignore cleanup errors. } if (code === 0) resolve(); else reject(new Error(`msiexec exited with code ${code}`)); }); child.on("error", reject); }); } export async function installTailscale({ sudoPassword, onProgress, }: { sudoPassword?: string; onProgress?: (message: string) => void; } = {}) { if (!isSupportedPlatform()) { throw new Error(`Unsupported platform for Tailscale install: ${process.platform}`); } const password = toNonEmptyString(sudoPassword) || getCachedPassword() || ""; if (password) { setCachedPassword(password); } onProgress?.("Checking existing Tailscale installation..."); const existingBinary = await resolveBinary(); if (existingBinary.binaryPath) { onProgress?.("Tailscale is already installed."); } else if (IS_WINDOWS) { onProgress?.("Downloading and installing Tailscale for Windows..."); await installTailscaleWindows(onProgress); } else if (IS_MAC) { onProgress?.("Installing Tailscale on macOS..."); await installTailscaleMac(password, onProgress); } else if (IS_LINUX) { onProgress?.("Installing Tailscale on Linux..."); await installTailscaleLinux(password, onProgress); } try { onProgress?.("Ensuring the Tailscale daemon is available..."); await startTailscaleDaemon({ sudoPassword: password }); } catch (error) { onProgress?.( `Install completed, but the daemon still needs manual attention: ${ error instanceof Error ? error.message : String(error) }` ); } const status = await getTailscaleTunnelStatus(); await updateStateFile({ binaryPath: status.binaryPath, installSource: status.installSource, lastError: null, installedAt: new Date().toISOString(), }); return status; }