| import fs from "fs"; |
| import path from "path"; |
| import os from "os"; |
| import crypto from "crypto"; |
| import { execSync, exec, spawn } from "child_process"; |
| import { promisify } from "util"; |
| import { execWithPassword } from "@/mitm/dns/dnsConfig"; |
| import { DATA_DIR } from "@/lib/dataDir.js"; |
|
|
| const execAsync = promisify(exec); |
|
|
| const BIN_DIR = path.join(DATA_DIR, "bin"); |
| const IS_MAC = os.platform() === "darwin"; |
| const IS_LINUX = os.platform() === "linux"; |
| const IS_WINDOWS = os.platform() === "win32"; |
| const TAILSCALE_BIN = path.join(BIN_DIR, IS_WINDOWS ? "tailscale.exe" : "tailscale"); |
|
|
| |
| const TAILSCALE_DIR = path.join(DATA_DIR, "tailscale"); |
| export const TAILSCALE_SOCKET = path.join(TAILSCALE_DIR, "tailscaled.sock"); |
| const SOCKET_FLAG = IS_WINDOWS ? [] : ["--socket", TAILSCALE_SOCKET]; |
|
|
| |
| const SYSTEM_TAILSCALE_SOCKET = IS_WINDOWS ? null : "/var/run/tailscale/tailscaled.sock"; |
| const SYSTEM_SOCKET_FLAG = SYSTEM_TAILSCALE_SOCKET ? ["--socket", SYSTEM_TAILSCALE_SOCKET] : []; |
|
|
| |
| const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; |
|
|
| |
| const UNIX_TAILSCALE_CANDIDATES = [ |
| "/usr/local/bin/tailscale", |
| "/opt/homebrew/bin/tailscale", |
| "/usr/sbin/tailscale", |
| "/usr/bin/tailscale", |
| "/snap/bin/tailscale", |
| ]; |
|
|
| |
| const PROBE_TTL_MS = 10000; |
| const PROBE_TIMEOUT_MS = 1500; |
|
|
| const binCache = { value: undefined, fetchedAt: 0, refreshing: false }; |
| const runningCache = { value: false, fetchedAt: 0, refreshing: false }; |
| const loggedInCache = { value: false, fetchedAt: 0, refreshing: false }; |
| const funnelUrlCache = { value: null, port: null, fetchedAt: 0, refreshing: false }; |
|
|
| function fallbackBin() { |
| if (fs.existsSync(TAILSCALE_BIN)) return TAILSCALE_BIN; |
| if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) return WINDOWS_TAILSCALE_BIN; |
| if (!IS_WINDOWS) return UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)) || null; |
| return null; |
| } |
|
|
| function bgRefreshBin() { |
| if (binCache.refreshing) return; |
| binCache.refreshing = true; |
| const cmd = IS_WINDOWS ? "where tailscale 2>nul" : "which tailscale 2>/dev/null"; |
| execAsync(cmd, { windowsHide: true, timeout: PROBE_TIMEOUT_MS, env: { ...process.env, PATH: EXTENDED_PATH } }) |
| .then(({ stdout }) => { |
| const sys = stdout.trim(); |
| binCache.value = sys || fallbackBin(); |
| }) |
| .catch(() => { binCache.value = fallbackBin(); }) |
| .finally(() => { |
| binCache.fetchedAt = Date.now(); |
| binCache.refreshing = false; |
| }); |
| } |
|
|
| |
| export function getTailscaleBin() { |
| if (Date.now() - binCache.fetchedAt > PROBE_TTL_MS) bgRefreshBin(); |
| |
| if (binCache.value === undefined) { |
| if (fs.existsSync(TAILSCALE_BIN)) binCache.value = TAILSCALE_BIN; |
| else if (IS_WINDOWS && fs.existsSync(WINDOWS_TAILSCALE_BIN)) binCache.value = WINDOWS_TAILSCALE_BIN; |
| else if (!IS_WINDOWS) { |
| const found = UNIX_TAILSCALE_CANDIDATES.find((p) => fs.existsSync(p)); |
| binCache.value = found || null; |
| } else binCache.value = null; |
| } |
| return binCache.value; |
| } |
|
|
| export function isTailscaleInstalled() { |
| return getTailscaleBin() !== null; |
| } |
|
|
| |
| function tsArgs(...args) { |
| return [...SOCKET_FLAG, ...args]; |
| } |
|
|
| |
| export async function isTailscaleLoggedInStrict() { |
| const bin = getTailscaleBin(); |
| if (!bin) return false; |
| try { |
| const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { |
| windowsHide: true, |
| env: { ...process.env, PATH: EXTENDED_PATH }, |
| timeout: 5000 |
| }); |
| const json = JSON.parse(stdout); |
| |
| const loggedIn = json.BackendState === "Running" && json.Self?.Online === true; |
| loggedInCache.value = loggedIn; |
| loggedInCache.fetchedAt = Date.now(); |
| return loggedIn; |
| } catch { |
| return false; |
| } |
| } |
|
|
| function bgRefreshLoggedIn() { |
| if (loggedInCache.refreshing) return; |
| const bin = getTailscaleBin(); |
| if (!bin) { |
| loggedInCache.value = false; |
| loggedInCache.fetchedAt = Date.now(); |
| return; |
| } |
| loggedInCache.refreshing = true; |
| |
| probeStatusAsync(bin) |
| .then((json) => { |
| loggedInCache.value = !!json && json.BackendState === "Running" && json.Self?.Online === true; |
| }) |
| .catch(() => { loggedInCache.value = false; }) |
| .finally(() => { |
| loggedInCache.fetchedAt = Date.now(); |
| loggedInCache.refreshing = false; |
| }); |
| } |
|
|
| |
| async function probeStatusAsync(bin) { |
| for (const socketArgs of [SOCKET_FLAG, SYSTEM_SOCKET_FLAG]) { |
| try { |
| const { stdout } = await execAsync(`"${bin}" ${socketArgs.join(" ")} status --json`, { |
| windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS, |
| }); |
| return JSON.parse(stdout); |
| } catch { } |
| } |
| return null; |
| } |
|
|
| |
| export function isTailscaleLoggedIn() { |
| if (Date.now() - loggedInCache.fetchedAt > PROBE_TTL_MS) bgRefreshLoggedIn(); |
| return loggedInCache.value; |
| } |
|
|
| function bgRefreshRunning() { |
| if (runningCache.refreshing) return; |
| const bin = getTailscaleBin(); |
| if (!bin) { |
| runningCache.value = false; |
| runningCache.fetchedAt = Date.now(); |
| return; |
| } |
| runningCache.refreshing = true; |
| execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS }) |
| .then(({ stdout }) => { |
| try { |
| const json = JSON.parse(stdout); |
| runningCache.value = Object.keys(json.AllowFunnel || {}).length > 0; |
| } catch { runningCache.value = false; } |
| }) |
| .catch(() => { runningCache.value = false; }) |
| .finally(() => { |
| runningCache.fetchedAt = Date.now(); |
| runningCache.refreshing = false; |
| }); |
| } |
|
|
| |
| export function isTailscaleRunning() { |
| if (Date.now() - runningCache.fetchedAt > PROBE_TTL_MS) bgRefreshRunning(); |
| return runningCache.value; |
| } |
|
|
| |
| |
| export async function isTailscaleRunningStrict() { |
| const bin = getTailscaleBin(); |
| if (!bin) return false; |
| try { |
| const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { |
| windowsHide: true, |
| timeout: PROBE_TIMEOUT_MS, |
| }); |
| const json = JSON.parse(stdout); |
| const running = Object.keys(json.AllowFunnel || {}).length > 0; |
| runningCache.value = running; |
| runningCache.fetchedAt = Date.now(); |
| return running; |
| } catch { |
| return false; |
| } |
| } |
|
|
| |
| export function isSystemDaemonRunning() { |
| if (IS_WINDOWS || !SYSTEM_TAILSCALE_SOCKET || !fs.existsSync(SYSTEM_TAILSCALE_SOCKET)) return false; |
| const bin = getTailscaleBin(); |
| if (!bin) return false; |
| try { |
| const out = execSync(`"${bin}" ${SYSTEM_SOCKET_FLAG.join(" ")} status --json`, { |
| encoding: "utf8", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS, |
| }); |
| return JSON.parse(out).BackendState === "Running"; |
| } catch { |
| return false; |
| } |
| } |
|
|
| function bgRefreshFunnelUrl(port) { |
| if (funnelUrlCache.refreshing) return; |
| const bin = getTailscaleBin(); |
| if (!bin) return; |
| funnelUrlCache.refreshing = true; |
| execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { windowsHide: true, timeout: PROBE_TIMEOUT_MS }) |
| .then(({ stdout }) => { |
| try { |
| const json = JSON.parse(stdout); |
| const dnsName = json.Self?.DNSName?.replace(/\.$/, ""); |
| funnelUrlCache.value = dnsName ? `https://${dnsName}` : null; |
| } catch { } |
| }) |
| .catch(() => { }) |
| .finally(() => { |
| funnelUrlCache.port = port; |
| funnelUrlCache.fetchedAt = Date.now(); |
| funnelUrlCache.refreshing = false; |
| }); |
| } |
|
|
| |
| function getActualFunnelUrl() { |
| const bin = getTailscaleBin(); |
| if (!bin) return null; |
| try { |
| const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { |
| encoding: "utf8", |
| windowsHide: true, |
| env: { ...process.env, PATH: EXTENDED_PATH }, |
| timeout: 5000, |
| }); |
| const json = JSON.parse(out); |
| const dnsName = json.Self?.DNSName?.replace(/\.$/, ""); |
| return dnsName ? `https://${dnsName}` : null; |
| } catch { return null; } |
| } |
|
|
| |
| export function getTailscaleFunnelUrl(port) { |
| if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) { |
| bgRefreshFunnelUrl(port); |
| } |
| return funnelUrlCache.value; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export async function installTailscale(sudoPassword, hostname, onProgress) { |
| const log = onProgress || (() => {}); |
| if (IS_WINDOWS) { |
| await installTailscaleWindows(log); |
| return { success: true }; |
| } |
| if (IS_MAC) await installTailscaleMac(sudoPassword, log); |
| else await installTailscaleLinux(sudoPassword, log); |
|
|
| log("Starting daemon..."); |
| await startDaemonWithPassword(sudoPassword); |
| log("Logging in..."); |
| return startLogin(hostname); |
| } |
|
|
| const EXTENDED_PATH = `/usr/local/bin:/opt/homebrew/bin:/usr/sbin:/usr/bin:/bin:/snap/bin:${process.env.PATH || ""}`; |
|
|
| function hasBrew() { |
| try { execSync("which brew", { stdio: "ignore", windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH } }); return true; } catch { return false; } |
| } |
|
|
| async function installTailscaleMac(sudoPassword, log) { |
| if (hasBrew()) { |
| log("Installing via Homebrew..."); |
| await new Promise((resolve, reject) => { |
| const child = spawn("brew", ["install", "tailscale"], { |
| stdio: ["ignore", "pipe", "pipe"], |
| windowsHide: true, |
| env: { ...process.env, PATH: EXTENDED_PATH } |
| }); |
| child.stdout.on("data", (d) => { |
| const line = d.toString().trim(); |
| if (line) log(line); |
| }); |
| child.stderr.on("data", (d) => { |
| const line = d.toString().trim(); |
| if (line) log(line); |
| }); |
| child.on("close", (c) => { |
| if (c === 0) resolve(); |
| else reject(new Error(`brew install failed (code ${c})`)); |
| }); |
| child.on("error", reject); |
| }); |
| return; |
| } |
|
|
| |
| const pkgUrl = "https://pkgs.tailscale.com/stable/tailscale-latest.pkg"; |
| const pkgPath = path.join(os.tmpdir(), "tailscale.pkg"); |
|
|
| log("Downloading Tailscale package..."); |
| await new Promise((resolve, reject) => { |
| const child = spawn("curl", ["-fL", "--progress-bar", pkgUrl, "-o", pkgPath], { |
| stdio: ["ignore", "pipe", "pipe"], |
| windowsHide: true |
| }); |
| child.stderr.on("data", (d) => { |
| const line = d.toString().trim(); |
| if (line) log(line); |
| }); |
| child.on("close", (c) => { |
| if (c === 0) resolve(); |
| else reject(new Error("Download failed")); |
| }); |
| child.on("error", reject); |
| }); |
|
|
| log("Installing package..."); |
| await new Promise((resolve, reject) => { |
| const child = spawn("sudo", ["-S", "installer", "-pkg", pkgPath, "-target", "/"], { |
| stdio: ["pipe", "pipe", "pipe"], |
| windowsHide: true |
| }); |
| let stderr = ""; |
| child.stderr.on("data", (d) => { stderr += d.toString(); }); |
| child.stdout.on("data", (d) => { |
| const line = d.toString().trim(); |
| if (line) log(line); |
| }); |
| child.on("close", (c) => { |
| try { execSync(`rm -f ${pkgPath}`, { stdio: "ignore", windowsHide: true }); } catch { } |
| if (c === 0) resolve(); |
| else { |
| const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) |
| ? "Wrong sudo password" |
| : stderr || `Exit code ${c}`; |
| reject(new Error(msg)); |
| } |
| }); |
| child.on("error", reject); |
| child.stdin.write(`${sudoPassword}\n`); |
| child.stdin.end(); |
| }); |
| } |
|
|
| async function installTailscaleLinux(sudoPassword, log) { |
| |
| if (typeof sudoPassword !== "string" || sudoPassword.includes("\n")) { |
| throw new Error("Invalid sudo password"); |
| } |
| log("Downloading install script..."); |
| return new Promise((resolve, reject) => { |
| const curlChild = spawn("curl", ["-fsSL", "https://tailscale.com/install.sh"], { |
| stdio: ["ignore", "pipe", "pipe"], |
| windowsHide: true |
| }); |
| let scriptContent = ""; |
| let curlErr = ""; |
| curlChild.stdout.on("data", (d) => { scriptContent += d.toString(); }); |
| curlChild.stderr.on("data", (d) => { curlErr += d.toString(); }); |
| curlChild.on("exit", (code) => { |
| if (code !== 0) return reject(new Error(`Failed to download install script: ${curlErr}`)); |
| log("Running install script..."); |
| |
| const tmpScript = path.join(os.tmpdir(), `tailscale-install-${crypto.randomBytes(8).toString("hex")}.sh`); |
| try { |
| fs.writeFileSync(tmpScript, scriptContent, { mode: 0o700 }); |
| } catch (e) { |
| return reject(new Error(`Failed to write install script: ${e.message}`)); |
| } |
| const cleanup = () => { try { fs.unlinkSync(tmpScript); } catch {} }; |
| const child = spawn("sudo", ["-S", "sh", tmpScript], { stdio: ["pipe", "pipe", "pipe"], windowsHide: true }); |
| let stderr = ""; |
| child.stdout.on("data", (d) => { |
| const line = d.toString().trim(); |
| if (line) log(line); |
| }); |
| child.stderr.on("data", (d) => { stderr += d.toString(); }); |
| child.on("close", (c) => { |
| cleanup(); |
| if (c === 0) resolve(); |
| else { |
| const msg = (stderr.includes("incorrect password") || stderr.includes("Sorry")) |
| ? "Wrong sudo password" |
| : stderr || `Exit code ${c}`; |
| reject(new Error(msg)); |
| } |
| }); |
| child.on("error", (e) => { cleanup(); reject(e); }); |
| child.stdin.write(`${sudoPassword}\n`); |
| child.stdin.end(); |
| }); |
| curlChild.on("error", reject); |
| }); |
| } |
|
|
| async function installTailscaleWindows(log) { |
| const msiUrl = "https://pkgs.tailscale.com/stable/tailscale-setup-latest-amd64.msi"; |
| const msiPath = path.join(os.tmpdir(), "tailscale-setup.msi"); |
|
|
| |
| log("Downloading Tailscale installer..."); |
| await new Promise((resolve, reject) => { |
| const child = spawn("curl.exe", ["-L", "-#", "-o", msiPath, msiUrl], { |
| stdio: ["ignore", "pipe", "pipe"], |
| windowsHide: true |
| }); |
| |
| let lastPct = ""; |
| child.stderr.on("data", (d) => { |
| const text = d.toString(); |
| const match = text.match(/(\d+\.\d)%/); |
| if (match && match[1] !== lastPct) { |
| lastPct = match[1]; |
| log(`Downloading... ${lastPct}%`); |
| } |
| }); |
| child.on("close", (c) => c === 0 ? resolve() : reject(new Error("Download failed"))); |
| child.on("error", reject); |
| }); |
|
|
| |
| log("Installing Tailscale (UAC prompt may appear)..."); |
| await new Promise((resolve, reject) => { |
| const args = `'/i','${msiPath}','TS_NOLAUNCH=true','/quiet','/norestart'`; |
| const child = spawn("powershell", [ |
| "-NoProfile", "-NonInteractive", "-Command", |
| `Start-Process msiexec -ArgumentList ${args} -Verb RunAs -Wait` |
| ], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); |
| child.stderr.on("data", (d) => { const l = d.toString().trim(); if (l) log(l); }); |
| child.on("close", (c) => { |
| try { fs.unlinkSync(msiPath); } catch { } |
| c === 0 ? resolve() : reject(new Error(`msiexec failed (code ${c})`)); |
| }); |
| child.on("error", reject); |
| }); |
|
|
| |
| log("Verifying installation..."); |
| const maxWait = 10000; |
| const start = Date.now(); |
| while (Date.now() - start < maxWait) { |
| if (fs.existsSync(WINDOWS_TAILSCALE_BIN)) { |
| log("Installation complete."); |
| return; |
| } |
| await new Promise((r) => setTimeout(r, 1000)); |
| } |
| throw new Error("Installation finished but tailscale.exe not found"); |
| } |
|
|
| |
| |
| async function ensureUserOwnedDir(dir) { |
| try { |
| if (!fs.existsSync(dir)) { |
| fs.mkdirSync(dir, { recursive: true }); |
| return; |
| } |
| const uid = process.getuid(); |
| const gid = process.getgid(); |
|
|
| |
| const needsChown = (() => { |
| const stack = [dir]; |
| while (stack.length) { |
| const cur = stack.pop(); |
| try { |
| const st = fs.statSync(cur); |
| if (st.uid !== uid) return true; |
| if (st.isDirectory()) { |
| for (const name of fs.readdirSync(cur)) stack.push(path.join(cur, name)); |
| } |
| } catch { } |
| } |
| return false; |
| })(); |
|
|
| if (!needsChown) return; |
|
|
| |
| try { |
| execSync(`chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 }); |
| } catch { |
| try { execSync(`sudo -n chown -R ${uid}:${gid} "${dir}"`, { stdio: "ignore", timeout: 3000 }); } catch { } |
| } |
| } catch { } |
| } |
|
|
| |
| function isDaemonTunMode() { |
| try { |
| const ps = execSync(`pgrep -af "tailscaled.*${TAILSCALE_SOCKET}"`, { encoding: "utf8", timeout: 2000 }).trim(); |
| if (!ps) return null; |
| return !ps.includes("--tun=userspace-networking"); |
| } catch { return null; } |
| } |
|
|
| |
| export function isDaemonAlive() { |
| return isDaemonTunMode() !== null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export async function startDaemonWithPassword(sudoPassword) { |
| if (IS_WINDOWS) { |
| |
| |
| const bin = getTailscaleBin(); |
| console.log("[Tailscale] win: net start Tailscale"); |
| try { execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 }); } |
| catch { } |
| if (!bin) return; |
| |
| for (let i = 0; i < 20; i++) { |
| try { |
| const out = execSync(`"${bin}" status --json`, { encoding: "utf8", windowsHide: true, timeout: 2000 }); |
| const j = JSON.parse(out); |
| if (j.BackendState && j.BackendState !== "NoState") { |
| console.log(`[Tailscale] win: BackendState=${j.BackendState} after ${i*500}ms`); |
| return; |
| } |
| } catch { } |
| await new Promise((r) => setTimeout(r, 500)); |
| } |
| console.log("[Tailscale] win: BackendState still NoState after poll"); |
| return; |
| } |
|
|
| const currentMode = isDaemonTunMode(); |
| |
| const wantTun = sudoPassword ? true : currentMode === true; |
|
|
| |
| if (currentMode !== null && currentMode === wantTun) { |
| try { |
| const bin = getTailscaleBin() || "tailscale"; |
| execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { |
| stdio: "ignore", windowsHide: true, |
| env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 3000 |
| }); |
| return; |
| } catch { } |
| } |
|
|
| |
| try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { } |
| if (sudoPassword) { |
| try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { } |
| } else { |
| try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { } |
| } |
| await new Promise((r) => setTimeout(r, 1500)); |
|
|
| |
| await ensureUserOwnedDir(TAILSCALE_DIR); |
|
|
| const tailscaledBin = IS_MAC ? "/usr/local/bin/tailscaled" : "tailscaled"; |
| const daemonArgs = [ |
| `--socket=${TAILSCALE_SOCKET}`, |
| `--statedir=${TAILSCALE_DIR}`, |
| ]; |
| if (!wantTun) daemonArgs.push("--tun=userspace-networking"); |
|
|
| if (wantTun) { |
| |
| const child = spawn("sudo", ["-S", tailscaledBin, ...daemonArgs], { |
| detached: true, |
| stdio: ["pipe", "ignore", "ignore"], |
| cwd: os.tmpdir(), |
| env: { ...process.env, PATH: EXTENDED_PATH }, |
| }); |
| child.stdin.write(`${sudoPassword}\n`); |
| child.stdin.end(); |
| child.unref(); |
| } else { |
| const child = spawn(tailscaledBin, daemonArgs, { |
| detached: true, |
| stdio: "ignore", |
| cwd: os.tmpdir(), |
| env: { ...process.env, PATH: EXTENDED_PATH }, |
| }); |
| child.unref(); |
| } |
|
|
| |
| await new Promise((r) => setTimeout(r, 3000)); |
| } |
|
|
| |
| function ensureDaemon() { |
| startDaemonWithPassword("").catch(() => {}); |
| } |
|
|
| |
| function getAuthUrlFromStatus() { |
| const bin = getTailscaleBin(); |
| if (!bin) return null; |
| try { |
| const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { |
| encoding: "utf8", windowsHide: true, timeout: 2000 |
| }); |
| const j = JSON.parse(out); |
| if (j.AuthURL) return j.AuthURL; |
| return null; |
| } catch { return null; } |
| } |
|
|
| |
| |
| |
| |
| |
| export function startLogin(hostname) { |
| const bin = getTailscaleBin(); |
| if (!bin) return Promise.reject(new Error("Tailscale not installed")); |
|
|
| return new Promise((resolve, reject) => { |
| |
| ensureDaemon(); |
|
|
| |
| if (isTailscaleLoggedIn()) { |
| resolve({ alreadyLoggedIn: true }); |
| return; |
| } |
|
|
| const args = tsArgs("up", "--accept-routes"); |
| if (hostname) args.push(`--hostname=${hostname}`); |
| const child = spawn(bin, args, { |
| stdio: ["ignore", "pipe", "pipe"], |
| detached: true, |
| windowsHide: true |
| }); |
|
|
| let resolved = false; |
| let output = ""; |
|
|
| const parseAuthUrl = (text) => { |
| const match = text.match(/https:\/\/login\.tailscale\.com\/a\/[a-zA-Z0-9]+/); |
| return match ? match[0] : null; |
| }; |
|
|
| const finishWithUrl = (url, source) => { |
| if (resolved) return; |
| resolved = true; |
| clearTimeout(timeout); |
| clearInterval(statusPoll); |
| console.log(`[Tailscale] login authUrl detected (${source})`); |
| child.unref(); |
| resolve({ authUrl: url }); |
| }; |
|
|
| |
| const statusPoll = setInterval(() => { |
| if (resolved) return; |
| const url = getAuthUrlFromStatus(); |
| if (url) finishWithUrl(url, "status"); |
| }, 500); |
|
|
| const timeout = setTimeout(() => { |
| if (resolved) return; |
| resolved = true; |
| clearInterval(statusPoll); |
| child.unref(); |
| const url = parseAuthUrl(output) || getAuthUrlFromStatus(); |
| if (url) resolve({ authUrl: url }); |
| else reject(new Error("tailscale up timed out without auth URL")); |
| }, 15000); |
|
|
| const handleData = (data) => { |
| output += data.toString(); |
| const url = parseAuthUrl(output); |
| if (url) finishWithUrl(url, "stdout"); |
| }; |
|
|
| child.stdout.on("data", handleData); |
| child.stderr.on("data", handleData); |
|
|
| child.on("error", (err) => { |
| if (resolved) return; |
| resolved = true; |
| clearTimeout(timeout); |
| clearInterval(statusPoll); |
| console.error(`[Tailscale] login spawn error: ${err.message}`); |
| reject(err); |
| }); |
|
|
| child.on("exit", (code) => { |
| if (resolved) return; |
| console.log(`[Tailscale] login exit code=${code}`); |
| |
| |
| const url = parseAuthUrl(output) || getAuthUrlFromStatus(); |
| if (url) { |
| finishWithUrl(url, "exit"); |
| return; |
| } |
| |
| if (isTailscaleLoggedIn()) { |
| resolved = true; |
| clearTimeout(timeout); |
| clearInterval(statusPoll); |
| resolve({ alreadyLoggedIn: true }); |
| return; |
| } |
| |
| }); |
| }); |
| } |
|
|
| |
| export async function startFunnel(port) { |
| const bin = getTailscaleBin(); |
| if (!bin) throw new Error("Tailscale not installed"); |
|
|
| |
| try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { } |
|
|
| return new Promise((resolve, reject) => { |
| const child = spawn(bin, tsArgs("funnel", "--bg", `${port}`), { |
| stdio: ["ignore", "pipe", "pipe"], |
| windowsHide: true |
| }); |
|
|
| let resolved = false; |
| let output = ""; |
|
|
| const timeout = setTimeout(() => { |
| if (resolved) return; |
| resolved = true; |
| |
| const url = getActualFunnelUrl() || getTailscaleFunnelUrl(port); |
| if (url) resolve({ tunnelUrl: url }); |
| else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`)); |
| }, 30000); |
|
|
| |
| const parseFunnelUrl = () => getActualFunnelUrl(); |
|
|
| let funnelNotEnabled = false; |
|
|
| const handleData = (data) => { |
| output += data.toString(); |
|
|
| if (output.includes("Funnel is not enabled")) funnelNotEnabled = true; |
|
|
| |
| if (funnelNotEnabled && !resolved) { |
| const enableMatch = output.match(/https:\/\/login\.tailscale\.com\/[^\s]+/); |
| if (enableMatch) { |
| resolved = true; |
| clearTimeout(timeout); |
| child.kill(); |
| resolve({ funnelNotEnabled: true, enableUrl: enableMatch[0] }); |
| return; |
| } |
| } |
|
|
| const url = parseFunnelUrl(); |
| if (url && !resolved) { |
| resolved = true; |
| clearTimeout(timeout); |
| resolve({ tunnelUrl: url }); |
| } |
| }; |
|
|
| child.stdout.on("data", handleData); |
| child.stderr.on("data", handleData); |
|
|
| child.on("exit", (code) => { |
| if (resolved) return; |
| resolved = true; |
| clearTimeout(timeout); |
| console.log(`[Tailscale] funnel exit code=${code} output="${output.trim().slice(0, 200)}"`); |
| const url = parseFunnelUrl() || getTailscaleFunnelUrl(port); |
| if (url) resolve({ tunnelUrl: url }); |
| else reject(new Error(`tailscale funnel failed (code ${code}): ${output.trim()}`)); |
| }); |
|
|
| child.on("error", (err) => { |
| if (resolved) return; |
| resolved = true; |
| clearTimeout(timeout); |
| reject(err); |
| }); |
| }); |
| } |
|
|
| |
| export async function provisionCert(hostname) { |
| const bin = getTailscaleBin(); |
| if (!bin || !hostname) return; |
| const certsDir = path.join(TAILSCALE_DIR, "certs"); |
| fs.mkdirSync(certsDir, { recursive: true }); |
| const certFile = path.join(certsDir, `${hostname}.crt`); |
| const keyFile = path.join(certsDir, `${hostname}.key`); |
| try { |
| await execAsync( |
| `"${bin}" ${SOCKET_FLAG.join(" ")} cert --cert-file "${certFile}" --key-file "${keyFile}" "${hostname}"`, |
| { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 30000 } |
| ); |
| console.log(`[Tailscale] cert provisioned for ${hostname}`); |
| } catch (e) { |
| console.warn(`[Tailscale] cert provision failed (non-fatal): ${e.message}`); |
| } |
| } |
|
|
| |
| export function stopFunnel() { |
| const bin = getTailscaleBin(); |
| if (!bin) return; |
| try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { } |
| } |
|
|
| |
| export async function stopDaemon(sudoPassword) { |
| |
| try { execSync("pkill -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { } |
|
|
| |
| try { execSync("pgrep -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { return; } |
|
|
| |
| if (!IS_WINDOWS) { |
| try { await execWithPassword("pkill -x tailscaled", sudoPassword || ""); } catch { } |
| } |
|
|
| |
| try { if (fs.existsSync(TAILSCALE_SOCKET)) fs.unlinkSync(TAILSCALE_SOCKET); } catch { } |
| } |
|
|