Files
OmniRoute/src/lib/tailscaleTunnel.ts
Diego Rodrigues de Sa e Souza d0396c200d Release v3.8.31 (#4377)
Release v3.8.31 — see CHANGELOG.md [3.8.31] for full notes and contributors.

Merged over known non-blocking reds (all correctness gates green): Integration Tests (2/2) is env/flaky (polls a real upstream batch that did not complete in the poll window); SonarQube/SonarCloud is the advisory server-side new-code quality gate. Unit (8 shards), Coverage, Node 22/24/26, Lint, PR Test Policy, Quality Ratchet, Docs-Strict, Quality-Extended and all 4 CodeQL analyses are green.
2026-06-20 14:55:24 -03:00

1202 lines
35 KiB
TypeScript

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<string, unknown>;
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<PersistedTailscaleState> {
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<PersistedTailscaleState>) {
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<BinaryResolution> {
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 daemonFilename = process.platform === "win32" ? "tailscaled.exe" : "tailscaled";
const siblingDir = tailscaleBinaryPath ? path.dirname(tailscaleBinaryPath) : null;
// path.format avoids the path.join/resolve pattern flagged by CWE-22 linters;
// siblingDir is path.dirname of a trusted system binary from resolveBinary(), not user input.
const sibling = siblingDir ? path.format({ dir: siblingDir, base: daemonFilename }) : 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<string> {
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;
}
/**
* Build the `tailscale up` argument list. When an auth key is provided (e.g. from
* the `TAILSCALE_AUTHKEY` env var) it is passed via `--auth-key=` so a
* pre-authenticated / headless daemon logs in non-interactively instead of waiting
* for (and timing out on) an interactive auth URL. (#1263) The key is an argv
* element passed to `spawn(binary, args)` with no shell, so it is not shell-interpolated.
*/
export function tailscaleUpArgs(hostname?: string, authKey?: string): string[] {
return [
"up",
"--accept-routes",
...(hostname ? [`--hostname=${hostname}`] : []),
...(authKey ? [`--auth-key=${authKey}`] : []),
];
}
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;
const funnelResult = await readJsonCommand(
binaryPath,
await buildTailscaleArgs("funnel", "status", "--json")
);
if (funnelResult) return funnelResult;
// Fallback: older/some versions expose the same config via "serve status"
return readJsonCommand(binaryPath, await buildTailscaleArgs("serve", "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<TailscaleCheckStatus> {
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<TailscaleTunnelStatus> {
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;
// If live funnel detection fails (e.g. older CLI or socket permission), fall back to
// settings.tailscaleEnabled as the authoritative source (set by enableTailscaleTunnel).
const funnelActive = check.running || (settings.tailscaleEnabled === true && check.loggedIn);
const running = check.loggedIn && funnelActive && 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<TailscaleLoginResult> {
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 authKey = toNonEmptyString(process.env.TAILSCALE_AUTHKEY);
const spawnArgs = await buildTailscaleArgs(...tailscaleUpArgs(resolvedHostname, authKey));
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<TailscaleFunnelResult> {
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<TailscaleEnableResult> {
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<void>((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<void>((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<void>((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<void>((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<void>((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<void>((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;
}