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"); // Custom socket for userspace-networking mode (no root required) 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]; // System daemon socket (sudo install: apt/snap/systemd) — read-only status detection const SYSTEM_TAILSCALE_SOCKET = IS_WINDOWS ? null : "/var/run/tailscale/tailscaled.sock"; const SYSTEM_SOCKET_FLAG = SYSTEM_TAILSCALE_SOCKET ? ["--socket", SYSTEM_TAILSCALE_SOCKET] : []; // Well-known Windows install path const WINDOWS_TAILSCALE_BIN = "C:\\Program Files\\Tailscale\\tailscale.exe"; // Common Unix install paths to probe synchronously (system tailscale) const UNIX_TAILSCALE_CANDIDATES = [ "/usr/local/bin/tailscale", "/opt/homebrew/bin/tailscale", "/usr/sbin/tailscale", // apt package on Debian/Ubuntu "/usr/bin/tailscale", "/snap/bin/tailscale", // Snap package ]; // ─── Cache + background refresh (avoid blocking event loop on dead daemon) ── 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; }); } // Sync getter: returns cached value, triggers background refresh if stale export function getTailscaleBin() { if (Date.now() - binCache.fetchedAt > PROBE_TTL_MS) bgRefreshBin(); // First call: synchronously probe common install paths (no exec, no event-loop block) 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; } /** Build tailscale CLI args with custom socket (no root needed) */ function tsArgs(...args) { return [...SOCKET_FLAG, ...args]; } // Async strict probe: authoritative, awaitable (never blocks event loop). Updates cache. 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); // BackendState=Running + Self.Online=true → device still exists in tailnet 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; // Dual-socket aware: probe custom socket first, then system socket 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; }); } // Probe `status --json` over custom then system socket. Resolves parsed JSON or null. Never blocks event loop. 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 { /* try next socket */ } } return null; } // Sync getter: never blocks; returns last known state, refreshes in background 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; }); } // Sync getter: never blocks; returns last known state, refreshes in background export function isTailscaleRunning() { if (Date.now() - runningCache.fetchedAt > PROBE_TTL_MS) bgRefreshRunning(); return runningCache.value; } // Async strict probe for hot user-initiated paths (enable/connect flow). // Awaitable, never blocks event loop; updates cache as a side effect. 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; } } // Check if a system-level tailscaled is running (uses system socket, not 9Router's custom one). 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 { /* keep prev */ } }) .catch(() => { /* keep prev */ }) .finally(() => { funnelUrlCache.port = port; funnelUrlCache.fetchedAt = Date.now(); funnelUrlCache.refreshing = false; }); } /** Get actual funnel URL from Self.DNSName (sync, authoritative — avoids hostname-conflict suffix). */ 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; } } /** Get funnel URL from tailscale status (cached, non-blocking) */ export function getTailscaleFunnelUrl(port) { if (Date.now() - funnelUrlCache.fetchedAt > PROBE_TTL_MS || funnelUrlCache.port !== port) { bgRefreshFunnelUrl(port); } return funnelUrlCache.value; } /** * Install tailscale. * - macOS + brew: brew install tailscale (no sudo needed) * - macOS no brew: download .pkg then sudo installer -pkg * - Linux: fetch install.sh, pipe to sudo -S sh via stdin * - Windows: download MSI via UAC-elevated PowerShell */ 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; } // No brew: download .pkg and install via sudo installer 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 { /* ignore */ } 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) { // Reject password containing newline → prevents stdin command injection 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..."); // Persist script to temp file → exec by path (NOT via stdin) → sh never reads attacker-controlled stdin 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"); // Download MSI via curl.exe (built-in on Win10+) — no PowerShell window, streams progress log("Downloading Tailscale installer..."); await new Promise((resolve, reject) => { const child = spawn("curl.exe", ["-L", "-#", "-o", msiPath, msiUrl], { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); // curl outputs progress to stderr with -# flag 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); }); // Install MSI with UAC elevation via PowerShell Start-Process -Verb RunAs 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 { /* ignore */ } c === 0 ? resolve() : reject(new Error(`msiexec failed (code ${c})`)); }); child.on("error", reject); }); // Verify tailscale.exe exists after install 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"); } // Self-heal: if state dir/files were previously created by root (e.g. legacy sudo daemon), // reclaim ownership recursively so the user-mode daemon can read/write state files. async function ensureUserOwnedDir(dir) { try { if (!fs.existsSync(dir)) { fs.mkdirSync(dir, { recursive: true }); return; } const uid = process.getuid(); const gid = process.getgid(); // Walk dir + all entries to find any non-user-owned items 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 { /* ignore */ } } return false; })(); if (!needsChown) return; // Try direct chown first (works if already owned). Fallback to passwordless sudo. 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 { /* ignore */ } } } catch { /* ignore */ } } /** Check if running daemon uses TUN mode (Funnel TLS requires TUN). */ 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; } } /** Daemon process alive (independent of funnel state) — mirrors cloudflared PID check semantic. */ export function isDaemonAlive() { return isDaemonTunMode() !== null; } /** * Start tailscaled. * - With sudoPassword: TUN mode (root) → Funnel TLS works * - Without: userspace-networking fallback (no sudo, but Funnel TLS unstable) * State always lives in ~/.9router/tailscale/ via --statedir. */ export async function startDaemonWithPassword(sudoPassword) { if (IS_WINDOWS) { // Windows: tailscale runs as a Windows Service. Start it then poll BackendState // until daemon finishes init (avoids "NoState" errors when calling funnel/up too early). const bin = getTailscaleBin(); console.log("[Tailscale] win: net start Tailscale"); try { execSync("net start Tailscale", { stdio: "ignore", windowsHide: true, timeout: 10000 }); } catch { /* may need admin, or already running */ } if (!bin) return; // Poll up to ~10s for backend to leave NoState 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 { /* daemon not ready */ } await new Promise((r) => setTimeout(r, 500)); } console.log("[Tailscale] win: BackendState still NoState after poll"); return; } const currentMode = isDaemonTunMode(); // true=TUN, false=userspace, null=not running // No password but a healthy TUN daemon already runs → keep TUN, never downgrade-kill it. const wantTun = sudoPassword ? true : currentMode === true; // Daemon already running in correct mode → reuse 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 { /* unresponsive, restart below */ } } // Mode mismatch or unresponsive → kill all daemons on our socket try { execSync(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } if (sudoPassword) { try { await execWithPassword(`pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, sudoPassword); } catch { /* ignore */ } } else { try { execSync(`sudo -n pkill -9 -f "tailscaled.*${TAILSCALE_SOCKET}"`, { stdio: "ignore", timeout: 3000 }); } catch { /* ignore */ } } await new Promise((r) => setTimeout(r, 1500)); // Reclaim folder ownership (previous root daemon may have locked it) 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) { // TUN mode: spawn via sudo, password via stdin. Detached so it survives parent exit. 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(); } // Wait for socket ready await new Promise((r) => setTimeout(r, 3000)); } /** Best-effort: ensure daemon running (used for login flow) */ function ensureDaemon() { startDaemonWithPassword("").catch(() => {}); } /** Read AuthURL from `tailscale status --json` (Win exposes it there, not stdout). */ 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; } } /** * Run `tailscale up` and capture the auth URL for browser login. * Resolves with { authUrl } or { alreadyLoggedIn: true }. * On Windows, AuthURL comes from `status --json` (not stdout) — must poll status. */ export function startLogin(hostname) { const bin = getTailscaleBin(); if (!bin) return Promise.reject(new Error("Tailscale not installed")); return new Promise((resolve, reject) => { // Ensure daemon is running (best-effort, no sudo) ensureDaemon(); // Check if already logged in 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 }); }; // Poll status --json every 500ms — Windows exposes AuthURL only there 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}`); // Don't trust exit code alone — Win `tailscale up` exits 0 even when not logged in. // Let status poll continue until AuthURL appears or timeout. const url = parseAuthUrl(output) || getAuthUrlFromStatus(); if (url) { finishWithUrl(url, "exit"); return; } // Only resolve alreadyLoggedIn if status confirms BackendState=Running if (isTailscaleLoggedIn()) { resolved = true; clearTimeout(timeout); clearInterval(statusPoll); resolve({ alreadyLoggedIn: true }); return; } // Otherwise keep polling — daemon may publish AuthURL shortly after exit }); }); } /** Start tailscale funnel for the given port */ export async function startFunnel(port) { const bin = getTailscaleBin(); if (!bin) throw new Error("Tailscale not installed"); // Reset any existing funnel try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { /* ignore */ } 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; // --bg exits after setup, read actual hostname from status const url = getActualFunnelUrl() || getTailscaleFunnelUrl(port); if (url) resolve({ tunnelUrl: url }); else reject(new Error(`Tailscale funnel timed out: ${output.trim() || "no output"}`)); }, 30000); // Always resolve via Self.DNSName to get the real hostname (avoids -1 suffix from conflicts) const parseFunnelUrl = () => getActualFunnelUrl(); let funnelNotEnabled = false; const handleData = (data) => { output += data.toString(); if (output.includes("Funnel is not enabled")) funnelNotEnabled = true; // Wait for the enable URL to arrive in a later chunk 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); }); }); } /** Provision TLS cert for funnel domain (required before Funnel serves HTTPS). Best-effort. */ 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}`); } } /** Stop tailscale funnel */ export function stopFunnel() { const bin = getTailscaleBin(); if (!bin) return; try { execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel --bg reset`, { stdio: "ignore", windowsHide: true }); } catch (e) { /* ignore */ } } /** Kill tailscaled daemon (runs as root, needs sudo) */ export async function stopDaemon(sudoPassword) { // Try non-sudo first try { execSync("pkill -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 3000 }); } catch { /* ignore */ } // Check if still alive try { execSync("pgrep -x tailscaled", { stdio: "ignore", windowsHide: true, timeout: 2000 }); } catch { return; } // Dead, done // Kill with sudo password if (!IS_WINDOWS) { try { await execWithPassword("pkill -x tailscaled", sudoPassword || ""); } catch { /* ignore */ } } // Cleanup socket try { if (fs.existsSync(TAILSCALE_SOCKET)) fs.unlinkSync(TAILSCALE_SOCKET); } catch { /* ignore */ } }