mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-04 22:32:12 +03:00
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.
1202 lines
35 KiB
TypeScript
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;
|
|
}
|