mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
* fix(windows): add windowsHide to all child process spawns (#8131) On Windows, child processes spawned without windowsHide: true cause transient conhost.exe/cmd console windows to flash open. Audited all spawn/exec/execFile/execSync/execFileSync call sites and added windowsHide: true where missing. Files patched: - src/mitm/manager.ts (MITM server spawn) - src/mitm/systemCommands.ts (sudo/system command spawn) - src/mitm/inspector/systemProxyConfig.ts (execFile wrapper) - src/shared/services/cliRuntime.ts (CLI spawn + npm execFileSync) - src/lib/plugins/loader.ts (plugin host spawn) - src/lib/providerModels/cursorAgent.ts (cursor binary spawn) - src/lib/cloudflaredTunnel.ts (cloudflared spawn) Unix-only call sites (shell: /bin/bash, which) are unaffected. electron/main.js already had windowsHide: true. * fix(windows): cover remaining spawn sites missed by #8131 windowsHide sweep Extends the #8131 windowsHide audit to the three call sites the original sweep missed: ServiceSupervisor.start() and processManager.startProcess() (both spawn() embedded-service child processes), and installers/utils.ts::buildNpmExecOptions() (the execFile() options runNpm() uses to install services). All three now always set windowsHide: true so no transient conhost.exe/cmd console window flashes open on Windows. The two spawn() options objects are factored into small, pure, exported builder functions (buildServiceSpawnOptions, buildCliproxyapiSpawnOptions) so the regression test can assert on the constructed options directly, since both call sites use a bare named `import { spawn } from "node:child_process"` that ESM live-binding semantics make unmockable without --experimental-test-module-mocks (not currently enabled repo-wide). Bumps config/quality/file-size-baseline.json for cloudflaredTunnel.ts 934->935 (the PR's own +1 windowsHide line at the existing spawn options object). Co-Authored-By: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com> --------- Co-authored-by: Austin Liu <austinliu@Austins-MacBook-Air-3.local> Co-authored-by: Probe Test <probe@example.com> Co-authored-by: Dingding-leo <Dingding-leo@users.noreply.github.com> Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
935 lines
25 KiB
TypeScript
935 lines
25 KiB
TypeScript
import { spawn, execFile } from "child_process";
|
|
import { createHash } from "crypto";
|
|
import { promisify } from "util";
|
|
import fs from "fs/promises";
|
|
import fsSync from "fs";
|
|
import path from "path";
|
|
import proxyFetch from "@omniroute/open-sse/utils/proxyFetch.ts";
|
|
import { resolveDataDir } from "@/lib/dataPaths";
|
|
import { getRuntimePorts } from "@/lib/runtime/ports";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
const CLOUDFLARED_RELEASE_API_URL =
|
|
"https://api.github.com/repos/cloudflare/cloudflared/releases/latest";
|
|
const START_TIMEOUT_MS = 30000;
|
|
const STOP_TIMEOUT_MS = 5000;
|
|
const GENERIC_EXIT_ERROR_PREFIX = "cloudflared exited";
|
|
const DEFAULT_CERT_FILE_CANDIDATES = [
|
|
"/etc/ssl/certs/ca-certificates.crt",
|
|
"/etc/pki/tls/certs/ca-bundle.crt",
|
|
"/etc/ssl/cert.pem",
|
|
"/private/etc/ssl/cert.pem",
|
|
] as const;
|
|
const DEFAULT_CERT_DIR_CANDIDATES = [
|
|
"/etc/ssl/certs",
|
|
"/etc/pki/tls/certs",
|
|
"/system/etc/security/cacerts",
|
|
] as const;
|
|
|
|
type CloudflaredInstallSource = "managed" | "path" | "env";
|
|
type TunnelPhase = "unsupported" | "not_installed" | "stopped" | "starting" | "running" | "error";
|
|
|
|
type AssetSpec = {
|
|
assetName: string;
|
|
binaryName: string;
|
|
archive: "none" | "tgz";
|
|
};
|
|
|
|
type ResolvedAssetSpec = AssetSpec & {
|
|
downloadUrl: string;
|
|
expectedSha256: string;
|
|
};
|
|
|
|
type CloudflaredRuntimeDirs = {
|
|
runtimeRoot: string;
|
|
homeDir: string;
|
|
configDir: string;
|
|
cacheDir: string;
|
|
dataDir: string;
|
|
tempDir: string;
|
|
userProfileDir: string;
|
|
appDataDir: string;
|
|
localAppDataDir: string;
|
|
};
|
|
|
|
type BinaryResolution = {
|
|
binaryPath: string | null;
|
|
source: CloudflaredInstallSource | null;
|
|
managed: boolean;
|
|
};
|
|
|
|
type PersistedTunnelState = {
|
|
binaryPath?: string | null;
|
|
installSource?: CloudflaredInstallSource | null;
|
|
ownerPid?: number | null;
|
|
pid?: number | null;
|
|
publicUrl?: string | null;
|
|
apiUrl?: string | null;
|
|
targetUrl?: string | null;
|
|
status?: TunnelPhase;
|
|
lastError?: string | null;
|
|
startedAt?: string | null;
|
|
installedAt?: string | null;
|
|
};
|
|
|
|
export type CloudflaredTunnelStatus = {
|
|
supported: boolean;
|
|
installed: boolean;
|
|
managedInstall: boolean;
|
|
installSource: CloudflaredInstallSource | null;
|
|
binaryPath: string | null;
|
|
running: boolean;
|
|
pid: number | null;
|
|
publicUrl: string | null;
|
|
apiUrl: string | null;
|
|
targetUrl: string;
|
|
phase: TunnelPhase;
|
|
lastError: string | null;
|
|
logPath: string;
|
|
};
|
|
|
|
const CLOUDFLARED_SAFE_ENV_KEYS = [
|
|
"PATH",
|
|
"HOME",
|
|
"USERPROFILE",
|
|
"APPDATA",
|
|
"LOCALAPPDATA",
|
|
"PROGRAMDATA",
|
|
"ProgramData",
|
|
"SYSTEMROOT",
|
|
"SystemRoot",
|
|
"WINDIR",
|
|
"ComSpec",
|
|
"COMSPEC",
|
|
"PATHEXT",
|
|
"TMPDIR",
|
|
"TMP",
|
|
"TEMP",
|
|
"USER",
|
|
"USERNAME",
|
|
"LOGNAME",
|
|
"SHELL",
|
|
"LANG",
|
|
"LC_ALL",
|
|
"LC_CTYPE",
|
|
"SSL_CERT_FILE",
|
|
"SSL_CERT_DIR",
|
|
"NODE_EXTRA_CA_CERTS",
|
|
"XDG_CONFIG_HOME",
|
|
"XDG_CACHE_HOME",
|
|
"XDG_DATA_HOME",
|
|
"XDG_RUNTIME_DIR",
|
|
"HTTP_PROXY",
|
|
"HTTPS_PROXY",
|
|
"ALL_PROXY",
|
|
"NO_PROXY",
|
|
"http_proxy",
|
|
"https_proxy",
|
|
"all_proxy",
|
|
"no_proxy",
|
|
] as const;
|
|
|
|
let tunnelProcess: ReturnType<typeof spawn> | null = null;
|
|
let tunnelPid: number | null = null;
|
|
let installPromise: Promise<string> | null = null;
|
|
let startPromise: Promise<CloudflaredTunnelStatus> | null = null;
|
|
let stateFileQueue: Promise<void> = Promise.resolve();
|
|
const NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS = [
|
|
/failed to sufficiently increase receive buffer size/i,
|
|
] as const;
|
|
|
|
function getTunnelDir() {
|
|
return path.join(resolveDataDir(), "cloudflared");
|
|
}
|
|
|
|
function getManagedBinaryPath(platform = process.platform) {
|
|
return path.join(getTunnelDir(), "bin", platform === "win32" ? "cloudflared.exe" : "cloudflared");
|
|
}
|
|
|
|
function getStateFilePath() {
|
|
return path.join(getTunnelDir(), "quick-tunnel-state.json");
|
|
}
|
|
|
|
function getPidFilePath() {
|
|
return path.join(getTunnelDir(), ".quick-tunnel.pid");
|
|
}
|
|
|
|
function getLogFilePath() {
|
|
return path.join(getTunnelDir(), "quick-tunnel.log");
|
|
}
|
|
|
|
export function getCloudflaredRuntimeDirs(): CloudflaredRuntimeDirs {
|
|
const runtimeRoot = path.join(getTunnelDir(), "runtime");
|
|
const homeDir = path.join(runtimeRoot, "home");
|
|
const userProfileDir = path.join(runtimeRoot, "userprofile");
|
|
|
|
return {
|
|
runtimeRoot,
|
|
homeDir,
|
|
configDir: path.join(runtimeRoot, "config"),
|
|
cacheDir: path.join(runtimeRoot, "cache"),
|
|
dataDir: path.join(runtimeRoot, "data"),
|
|
tempDir: path.join(runtimeRoot, "tmp"),
|
|
userProfileDir,
|
|
appDataDir: path.join(userProfileDir, "AppData", "Roaming"),
|
|
localAppDataDir: path.join(userProfileDir, "AppData", "Local"),
|
|
};
|
|
}
|
|
|
|
function getLocalTargetUrl() {
|
|
const { apiPort } = getRuntimePorts();
|
|
return `http://127.0.0.1:${apiPort}`;
|
|
}
|
|
|
|
function getTunnelApiUrl(publicUrl: string | null) {
|
|
return publicUrl ? `${publicUrl.replace(/\/$/, "")}/v1` : null;
|
|
}
|
|
|
|
async function ensureTunnelDir() {
|
|
await fs.mkdir(path.join(getTunnelDir(), "bin"), { recursive: true });
|
|
}
|
|
|
|
async function ensureTunnelRuntimeDirs() {
|
|
const runtimeDirs = getCloudflaredRuntimeDirs();
|
|
await Promise.all(
|
|
Object.values(runtimeDirs).map((dirPath) => fs.mkdir(dirPath, { recursive: true }))
|
|
);
|
|
}
|
|
|
|
async function readStateFile(): Promise<PersistedTunnelState> {
|
|
try {
|
|
const content = await fs.readFile(getStateFilePath(), "utf8");
|
|
return JSON.parse(content) as PersistedTunnelState;
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
async function writeStateFileNow(state: PersistedTunnelState) {
|
|
const stateFilePath = getStateFilePath();
|
|
await fs.mkdir(path.dirname(stateFilePath), { recursive: true });
|
|
await fs.writeFile(stateFilePath, JSON.stringify(state, null, 2) + "\n", "utf8");
|
|
}
|
|
|
|
async function withStateFileLock<T>(operation: () => Promise<T>): Promise<T> {
|
|
const previous = stateFileQueue;
|
|
let release!: () => void;
|
|
stateFileQueue = new Promise<void>((resolve) => {
|
|
release = resolve;
|
|
});
|
|
|
|
await previous;
|
|
try {
|
|
return await operation();
|
|
} finally {
|
|
release();
|
|
}
|
|
}
|
|
|
|
async function writeStateFile(state: PersistedTunnelState) {
|
|
await withStateFileLock(() => writeStateFileNow(state));
|
|
}
|
|
|
|
async function updateStateFile(patch: PersistedTunnelState) {
|
|
await withStateFileLock(async () => {
|
|
const current = await readStateFile();
|
|
await writeStateFileNow({ ...current, ...patch });
|
|
});
|
|
}
|
|
|
|
async function clearPidFile() {
|
|
try {
|
|
await fs.unlink(getPidFilePath());
|
|
} catch {
|
|
// Ignore missing/stale pid files.
|
|
}
|
|
}
|
|
|
|
async function writePidFile(pid: number) {
|
|
await ensureTunnelDir();
|
|
await fs.writeFile(getPidFilePath(), String(pid), "utf8");
|
|
}
|
|
|
|
async function readPidFile() {
|
|
try {
|
|
const content = await fs.readFile(getPidFilePath(), "utf8");
|
|
const pid = Number.parseInt(content.trim(), 10);
|
|
return Number.isFinite(pid) ? pid : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isProcessAlive(pid: number | null) {
|
|
if (!pid || pid <= 0) return false;
|
|
try {
|
|
process.kill(pid, 0);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function appendTunnelLog(source: "stdout" | "stderr", message: string) {
|
|
await ensureTunnelDir();
|
|
const timestamp = new Date().toISOString();
|
|
await fs.appendFile(getLogFilePath(), `[${timestamp}] [${source}] ${message}\n`, "utf8");
|
|
}
|
|
|
|
export function extractTryCloudflareUrl(text: string) {
|
|
const match = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com\b/i);
|
|
if (!match) return null;
|
|
|
|
try {
|
|
const hostname = new URL(match[0]).hostname.toLowerCase();
|
|
if (hostname === "api.trycloudflare.com") return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return match[0];
|
|
}
|
|
|
|
function normalizeCloudflaredLogLine(line: string) {
|
|
return line
|
|
.trim()
|
|
.replace(/^\d{4}-\d{2}-\d{2}T\S+\s+(?:INF|WRN|ERR)\s+/i, "")
|
|
.trim();
|
|
}
|
|
|
|
export function extractCloudflaredErrorMessage(text: string) {
|
|
const lines = String(text || "")
|
|
.split(/\r?\n/)
|
|
.map(normalizeCloudflaredLogLine)
|
|
.filter(Boolean);
|
|
|
|
for (let i = lines.length - 1; i >= 0; i--) {
|
|
if (NON_ACTIONABLE_CLOUDFLARED_WARNING_PATTERNS.some((pattern) => pattern.test(lines[i]))) {
|
|
continue;
|
|
}
|
|
if (/(?:\berror\b|\bfailed\b|\btls:\b|\bx509\b|\bcertificate\b)/i.test(lines[i])) {
|
|
return lines[i];
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function isSpecificCloudflaredError(error: string | null | undefined) {
|
|
return !!error && !error.startsWith(GENERIC_EXIT_ERROR_PREFIX);
|
|
}
|
|
|
|
function getGenericExitError(code: number | null, signal: NodeJS.Signals | null) {
|
|
return `cloudflared exited unexpectedly (${code ?? "signal"}${signal ? `/${signal}` : ""})`;
|
|
}
|
|
|
|
export function getDefaultCloudflaredCertEnv(
|
|
existsSync: (candidate: string) => boolean = fsSync.existsSync,
|
|
certFileCandidates: readonly string[] = DEFAULT_CERT_FILE_CANDIDATES,
|
|
certDirCandidates: readonly string[] = DEFAULT_CERT_DIR_CANDIDATES
|
|
) {
|
|
const certEnv: NodeJS.ProcessEnv = {};
|
|
const certFile = certFileCandidates.find((candidate) => existsSync(candidate));
|
|
const certDir = certDirCandidates.find((candidate) => existsSync(candidate));
|
|
|
|
if (certFile) certEnv.SSL_CERT_FILE = certFile;
|
|
if (certDir) certEnv.SSL_CERT_DIR = certDir;
|
|
|
|
return certEnv;
|
|
}
|
|
|
|
export function buildCloudflaredChildEnv(
|
|
sourceEnv: NodeJS.ProcessEnv = process.env,
|
|
runtimeDirs: CloudflaredRuntimeDirs = getCloudflaredRuntimeDirs(),
|
|
defaultCertEnv: NodeJS.ProcessEnv = getDefaultCloudflaredCertEnv()
|
|
): NodeJS.ProcessEnv {
|
|
const childEnv: NodeJS.ProcessEnv = {};
|
|
|
|
for (const key of CLOUDFLARED_SAFE_ENV_KEYS) {
|
|
const value = sourceEnv[key];
|
|
if (typeof value === "string" && value.length > 0) {
|
|
childEnv[key] = value;
|
|
}
|
|
}
|
|
|
|
childEnv.HOME = runtimeDirs.homeDir;
|
|
childEnv.XDG_CONFIG_HOME = runtimeDirs.configDir;
|
|
childEnv.XDG_CACHE_HOME = runtimeDirs.cacheDir;
|
|
childEnv.XDG_DATA_HOME = runtimeDirs.dataDir;
|
|
childEnv.USERPROFILE = runtimeDirs.userProfileDir;
|
|
childEnv.APPDATA = runtimeDirs.appDataDir;
|
|
childEnv.LOCALAPPDATA = runtimeDirs.localAppDataDir;
|
|
|
|
if (!childEnv.TMPDIR) childEnv.TMPDIR = runtimeDirs.tempDir;
|
|
if (!childEnv.TMP) childEnv.TMP = runtimeDirs.tempDir;
|
|
if (!childEnv.TEMP) childEnv.TEMP = runtimeDirs.tempDir;
|
|
if (!childEnv.SSL_CERT_FILE && defaultCertEnv.SSL_CERT_FILE) {
|
|
childEnv.SSL_CERT_FILE = defaultCertEnv.SSL_CERT_FILE;
|
|
}
|
|
if (!childEnv.SSL_CERT_DIR && defaultCertEnv.SSL_CERT_DIR) {
|
|
childEnv.SSL_CERT_DIR = defaultCertEnv.SSL_CERT_DIR;
|
|
}
|
|
|
|
const requestedProtocol = String(
|
|
sourceEnv.CLOUDFLARED_PROTOCOL || sourceEnv.TUNNEL_TRANSPORT_PROTOCOL || "http2"
|
|
)
|
|
.trim()
|
|
.toLowerCase();
|
|
const protocol =
|
|
requestedProtocol === "quic" || requestedProtocol === "auto" ? requestedProtocol : "http2";
|
|
|
|
if (protocol !== "auto") {
|
|
childEnv.TUNNEL_TRANSPORT_PROTOCOL = protocol;
|
|
}
|
|
|
|
return childEnv;
|
|
}
|
|
|
|
export function getCloudflaredStartArgs(targetUrl: string) {
|
|
return ["tunnel", "--url", targetUrl, "--no-autoupdate"];
|
|
}
|
|
|
|
function isStateOwnedByCurrentProcess(state: PersistedTunnelState) {
|
|
return !!state.ownerPid && state.ownerPid === process.pid;
|
|
}
|
|
|
|
function hasTransientRuntimeState(state: PersistedTunnelState) {
|
|
return !!(
|
|
state.ownerPid ||
|
|
state.pid ||
|
|
state.publicUrl ||
|
|
state.apiUrl ||
|
|
state.startedAt ||
|
|
state.status === "running" ||
|
|
state.status === "starting" ||
|
|
state.status === "error"
|
|
);
|
|
}
|
|
|
|
function buildStoppedState(
|
|
state: PersistedTunnelState,
|
|
binaryResolved: boolean,
|
|
targetUrl = getLocalTargetUrl()
|
|
): PersistedTunnelState {
|
|
return {
|
|
...state,
|
|
ownerPid: null,
|
|
pid: null,
|
|
publicUrl: null,
|
|
apiUrl: null,
|
|
targetUrl,
|
|
status: binaryResolved ? "stopped" : "not_installed",
|
|
lastError: null,
|
|
startedAt: null,
|
|
};
|
|
}
|
|
|
|
export function getCloudflaredAssetSpec(
|
|
platform = process.platform,
|
|
arch = process.arch
|
|
): AssetSpec | null {
|
|
const matrix: Record<string, Record<string, AssetSpec>> = {
|
|
linux: {
|
|
x64: {
|
|
assetName: "cloudflared-linux-amd64",
|
|
binaryName: "cloudflared",
|
|
archive: "none",
|
|
},
|
|
arm64: {
|
|
assetName: "cloudflared-linux-arm64",
|
|
binaryName: "cloudflared",
|
|
archive: "none",
|
|
},
|
|
},
|
|
darwin: {
|
|
x64: {
|
|
assetName: "cloudflared-darwin-amd64.tgz",
|
|
binaryName: "cloudflared",
|
|
archive: "tgz",
|
|
},
|
|
arm64: {
|
|
assetName: "cloudflared-darwin-arm64.tgz",
|
|
binaryName: "cloudflared",
|
|
archive: "tgz",
|
|
},
|
|
},
|
|
win32: {
|
|
x64: {
|
|
assetName: "cloudflared-windows-amd64.exe",
|
|
binaryName: "cloudflared.exe",
|
|
archive: "none",
|
|
},
|
|
arm64: {
|
|
assetName: "cloudflared-windows-arm64.exe",
|
|
binaryName: "cloudflared.exe",
|
|
archive: "none",
|
|
},
|
|
},
|
|
};
|
|
|
|
const spec = matrix[platform]?.[arch];
|
|
if (!spec) return null;
|
|
|
|
return spec;
|
|
}
|
|
|
|
export function getSha256FromGitHubDigest(digest: string): string | null {
|
|
const prefix = "sha256:";
|
|
if (!digest.toLowerCase().startsWith(prefix)) return null;
|
|
|
|
const value = digest.slice(prefix.length);
|
|
if (value.length !== 64) return null;
|
|
for (const char of value) {
|
|
const code = char.charCodeAt(0);
|
|
const digit = code >= 48 && code <= 57;
|
|
const lowerHex = code >= 97 && code <= 102;
|
|
const upperHex = code >= 65 && code <= 70;
|
|
if (!digit && !lowerHex && !upperHex) return null;
|
|
}
|
|
|
|
return value.toLowerCase();
|
|
}
|
|
|
|
export function verifyCloudflaredDownloadDigest(
|
|
buffer: Buffer,
|
|
expectedSha256: string,
|
|
assetName = "cloudflared"
|
|
): void {
|
|
const actualSha256 = createHash("sha256").update(buffer).digest("hex");
|
|
if (actualSha256 !== expectedSha256.toLowerCase()) {
|
|
throw new Error(
|
|
`cloudflared download checksum mismatch for ${assetName}: expected ${expectedSha256}, got ${actualSha256}`
|
|
);
|
|
}
|
|
}
|
|
|
|
async function resolveCloudflaredDownloadSpec(spec: AssetSpec): Promise<ResolvedAssetSpec> {
|
|
const response = await proxyFetch(CLOUDFLARED_RELEASE_API_URL, {
|
|
headers: { Accept: "application/vnd.github+json" },
|
|
redirect: "follow",
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error(
|
|
`Failed to resolve cloudflared release metadata with status ${response.status}`
|
|
);
|
|
}
|
|
|
|
const release = (await response.json()) as { assets?: unknown };
|
|
const assets = Array.isArray(release.assets) ? release.assets : [];
|
|
const asset = assets
|
|
.map((entry) =>
|
|
entry && typeof entry === "object" ? (entry as Record<string, unknown>) : null
|
|
)
|
|
.find((entry) => entry?.name === spec.assetName);
|
|
|
|
if (!asset) {
|
|
throw new Error(`cloudflared release asset not found: ${spec.assetName}`);
|
|
}
|
|
|
|
const downloadUrl =
|
|
typeof asset.browser_download_url === "string" ? asset.browser_download_url : "";
|
|
const digest = typeof asset.digest === "string" ? asset.digest : "";
|
|
const expectedSha256 = getSha256FromGitHubDigest(digest);
|
|
|
|
if (!downloadUrl || !expectedSha256) {
|
|
throw new Error(`cloudflared release asset ${spec.assetName} is missing a sha256 digest`);
|
|
}
|
|
|
|
return {
|
|
...spec,
|
|
downloadUrl,
|
|
expectedSha256,
|
|
};
|
|
}
|
|
|
|
async function resolvePathCommand(command: string) {
|
|
const lookupCommand = process.platform === "win32" ? "where" : "which";
|
|
const args = [command];
|
|
|
|
try {
|
|
const { stdout } = await execFileAsync(lookupCommand, args, { timeout: 3000 });
|
|
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 = String(process.env.CLOUDFLARED_BIN || "").trim();
|
|
if (envPath && fsSync.existsSync(envPath)) {
|
|
return { binaryPath: envPath, source: "env", managed: false };
|
|
}
|
|
|
|
const managedPath = getManagedBinaryPath();
|
|
if (fsSync.existsSync(managedPath)) {
|
|
return { binaryPath: managedPath, source: "managed", managed: true };
|
|
}
|
|
|
|
const pathBinary = await resolvePathCommand("cloudflared");
|
|
if (pathBinary) {
|
|
return { binaryPath: pathBinary, source: "path", managed: false };
|
|
}
|
|
|
|
return { binaryPath: null, source: null, managed: false };
|
|
}
|
|
|
|
async function extractArchive(archivePath: string, destinationDir: string) {
|
|
await execFileAsync("tar", ["-xzf", archivePath, "-C", destinationDir], { timeout: 15000 });
|
|
}
|
|
|
|
async function downloadToFile(
|
|
url: string,
|
|
destinationPath: string,
|
|
expectedSha256: string,
|
|
assetName: string
|
|
) {
|
|
const response = await proxyFetch(url, { redirect: "follow" });
|
|
if (!response.ok) {
|
|
throw new Error(`Download failed with status ${response.status}`);
|
|
}
|
|
|
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
verifyCloudflaredDownloadDigest(buffer, expectedSha256, assetName);
|
|
await fs.writeFile(destinationPath, buffer);
|
|
}
|
|
|
|
async function ensureExecutable(binaryPath: string) {
|
|
if (process.platform !== "win32") {
|
|
await fs.chmod(binaryPath, 0o755);
|
|
}
|
|
}
|
|
|
|
async function installManagedBinary() {
|
|
if (installPromise) return installPromise;
|
|
|
|
installPromise = (async () => {
|
|
const spec = getCloudflaredAssetSpec();
|
|
if (!spec) {
|
|
throw new Error(
|
|
`Unsupported platform for managed cloudflared install: ${process.platform}/${process.arch}`
|
|
);
|
|
}
|
|
|
|
await ensureTunnelDir();
|
|
const managedBinaryPath = getManagedBinaryPath();
|
|
const tempDownloadPath = path.join(getTunnelDir(), `${spec.assetName}.download`);
|
|
|
|
await updateStateFile({
|
|
status: "starting",
|
|
lastError: null,
|
|
});
|
|
|
|
try {
|
|
const downloadSpec = await resolveCloudflaredDownloadSpec(spec);
|
|
await downloadToFile(
|
|
downloadSpec.downloadUrl,
|
|
tempDownloadPath,
|
|
downloadSpec.expectedSha256,
|
|
downloadSpec.assetName
|
|
);
|
|
|
|
if (spec.archive === "tgz") {
|
|
await extractArchive(tempDownloadPath, path.dirname(managedBinaryPath));
|
|
} else {
|
|
await fs.rename(tempDownloadPath, managedBinaryPath);
|
|
}
|
|
|
|
await ensureExecutable(managedBinaryPath);
|
|
await updateStateFile({
|
|
binaryPath: managedBinaryPath,
|
|
installSource: "managed",
|
|
installedAt: new Date().toISOString(),
|
|
lastError: null,
|
|
});
|
|
|
|
return managedBinaryPath;
|
|
} finally {
|
|
try {
|
|
await fs.unlink(tempDownloadPath);
|
|
} catch {
|
|
// Ignore temp cleanup issues.
|
|
}
|
|
installPromise = null;
|
|
}
|
|
})();
|
|
|
|
return installPromise;
|
|
}
|
|
|
|
async function ensureBinary() {
|
|
const resolved = await resolveBinary();
|
|
if (resolved.binaryPath) {
|
|
return resolved;
|
|
}
|
|
|
|
const binaryPath = await installManagedBinary();
|
|
return {
|
|
binaryPath,
|
|
source: "managed" as const,
|
|
managed: true,
|
|
};
|
|
}
|
|
|
|
async function finalizeProcessExit(code: number | null, signal: NodeJS.Signals | null) {
|
|
const currentState = await readStateFile();
|
|
const lastError =
|
|
code === 0 || signal === "SIGTERM" || signal === "SIGINT"
|
|
? null
|
|
: isSpecificCloudflaredError(currentState.lastError)
|
|
? currentState.lastError
|
|
: getGenericExitError(code, signal);
|
|
|
|
tunnelProcess = null;
|
|
tunnelPid = null;
|
|
await clearPidFile();
|
|
await writeStateFile({
|
|
...currentState,
|
|
pid: null,
|
|
publicUrl: null,
|
|
apiUrl: null,
|
|
status: lastError ? "error" : "stopped",
|
|
lastError,
|
|
});
|
|
}
|
|
|
|
async function killPid(pid: number) {
|
|
process.kill(pid, "SIGTERM");
|
|
const start = Date.now();
|
|
while (Date.now() - start < STOP_TIMEOUT_MS) {
|
|
if (!isProcessAlive(pid)) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
}
|
|
|
|
if (isProcessAlive(pid)) {
|
|
process.kill(pid, "SIGKILL");
|
|
}
|
|
}
|
|
|
|
async function stopExistingTunnel() {
|
|
if (tunnelProcess && tunnelPid && !tunnelProcess.killed) {
|
|
const pid = tunnelPid;
|
|
tunnelProcess.kill("SIGTERM");
|
|
await killPid(pid);
|
|
return;
|
|
}
|
|
|
|
const state = await readStateFile();
|
|
if (!isStateOwnedByCurrentProcess(state)) {
|
|
await clearPidFile();
|
|
return;
|
|
}
|
|
|
|
const pid = await readPidFile();
|
|
if (pid && isProcessAlive(pid)) {
|
|
await killPid(pid);
|
|
}
|
|
}
|
|
|
|
export async function getCloudflaredTunnelStatus(): Promise<CloudflaredTunnelStatus> {
|
|
const state = await readStateFile();
|
|
const resolved = await resolveBinary();
|
|
const pidFromState =
|
|
tunnelPid || (isStateOwnedByCurrentProcess(state) ? state.pid || (await readPidFile()) : null);
|
|
const running = isProcessAlive(pidFromState);
|
|
const needsColdStartReset =
|
|
!running && !isStateOwnedByCurrentProcess(state) && hasTransientRuntimeState(state);
|
|
const effectiveState = needsColdStartReset
|
|
? buildStoppedState(state, !!resolved.binaryPath)
|
|
: state;
|
|
|
|
if (needsColdStartReset) {
|
|
await writeStateFile(effectiveState);
|
|
}
|
|
|
|
const publicUrl = running ? effectiveState.publicUrl || null : null;
|
|
const phase =
|
|
!getCloudflaredAssetSpec() && !resolved.binaryPath
|
|
? "unsupported"
|
|
: running
|
|
? publicUrl
|
|
? "running"
|
|
: "starting"
|
|
: resolved.binaryPath
|
|
? effectiveState.lastError
|
|
? "error"
|
|
: "stopped"
|
|
: "not_installed";
|
|
|
|
if (!running && state.pid) {
|
|
await clearPidFile();
|
|
}
|
|
|
|
return {
|
|
supported: !!(getCloudflaredAssetSpec() || resolved.binaryPath),
|
|
installed: !!resolved.binaryPath,
|
|
managedInstall: resolved.managed,
|
|
installSource: resolved.source,
|
|
binaryPath: resolved.binaryPath,
|
|
running,
|
|
pid: running ? pidFromState : null,
|
|
publicUrl,
|
|
apiUrl: publicUrl ? getTunnelApiUrl(publicUrl) : null,
|
|
targetUrl: effectiveState.targetUrl || getLocalTargetUrl(),
|
|
phase,
|
|
lastError: running ? null : effectiveState.lastError || null,
|
|
logPath: getLogFilePath(),
|
|
};
|
|
}
|
|
|
|
export async function startCloudflaredTunnel(): Promise<CloudflaredTunnelStatus> {
|
|
const current = await getCloudflaredTunnelStatus();
|
|
if (current.running) return current;
|
|
if (startPromise) return startPromise;
|
|
|
|
startPromise = (async () => {
|
|
const spec = getCloudflaredAssetSpec();
|
|
if (!spec && !(await resolveBinary()).binaryPath) {
|
|
throw new Error(
|
|
`Unsupported platform for cloudflared tunnel: ${process.platform}/${process.arch}`
|
|
);
|
|
}
|
|
|
|
const binary = await ensureBinary();
|
|
const targetUrl = getLocalTargetUrl();
|
|
|
|
await stopExistingTunnel();
|
|
await ensureTunnelDir();
|
|
await ensureTunnelRuntimeDirs();
|
|
await fs.writeFile(getLogFilePath(), "", "utf8");
|
|
|
|
await writeStateFile({
|
|
binaryPath: binary.binaryPath,
|
|
installSource: binary.source,
|
|
ownerPid: process.pid,
|
|
pid: null,
|
|
publicUrl: null,
|
|
apiUrl: null,
|
|
targetUrl,
|
|
status: "starting",
|
|
lastError: null,
|
|
startedAt: new Date().toISOString(),
|
|
});
|
|
|
|
const child = spawn(binary.binaryPath as string, getCloudflaredStartArgs(targetUrl), {
|
|
windowsHide: true,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: buildCloudflaredChildEnv(),
|
|
});
|
|
|
|
tunnelProcess = child;
|
|
tunnelPid = child.pid ?? null;
|
|
|
|
if (!child.pid) {
|
|
throw new Error("cloudflared failed to start");
|
|
}
|
|
|
|
await writePidFile(child.pid);
|
|
await updateStateFile({ pid: child.pid, status: "starting" });
|
|
|
|
const ready = await new Promise<CloudflaredTunnelStatus>((resolve, reject) => {
|
|
let settled = false;
|
|
let timeout: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const settle = (handler: () => void) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (timeout) clearTimeout(timeout);
|
|
handler();
|
|
};
|
|
|
|
const handleOutput = async (source: "stdout" | "stderr", chunk: Buffer) => {
|
|
const text = chunk.toString("utf8").trim();
|
|
if (!text) return;
|
|
|
|
await appendTunnelLog(source, text);
|
|
const errorMessage = source === "stderr" ? extractCloudflaredErrorMessage(text) : null;
|
|
if (errorMessage) {
|
|
await updateStateFile({
|
|
ownerPid: process.pid,
|
|
pid: child.pid,
|
|
status: "error",
|
|
lastError: errorMessage,
|
|
});
|
|
}
|
|
const url = extractTryCloudflareUrl(text);
|
|
if (!url) return;
|
|
|
|
const apiUrl = getTunnelApiUrl(url);
|
|
await updateStateFile({
|
|
ownerPid: process.pid,
|
|
pid: child.pid,
|
|
publicUrl: url,
|
|
apiUrl,
|
|
status: "running",
|
|
lastError: null,
|
|
});
|
|
|
|
const status = await getCloudflaredTunnelStatus();
|
|
settle(() => resolve(status));
|
|
};
|
|
|
|
child.stdout.on("data", (chunk: Buffer) => {
|
|
void handleOutput("stdout", chunk);
|
|
});
|
|
child.stderr.on("data", (chunk: Buffer) => {
|
|
void handleOutput("stderr", chunk);
|
|
});
|
|
|
|
child.once("exit", (code, signal) => {
|
|
void finalizeProcessExit(code, signal);
|
|
settle(() =>
|
|
reject(
|
|
new Error(
|
|
`cloudflared exited before tunnel URL was ready (${code ?? "signal"}${signal ? `/${signal}` : ""})`
|
|
)
|
|
)
|
|
);
|
|
});
|
|
|
|
timeout = setTimeout(async () => {
|
|
await stopExistingTunnel();
|
|
settle(() => reject(new Error("Timed out while waiting for Cloudflare tunnel URL")));
|
|
}, START_TIMEOUT_MS);
|
|
});
|
|
|
|
return ready;
|
|
})();
|
|
|
|
try {
|
|
return await startPromise;
|
|
} catch (error) {
|
|
const currentState = await readStateFile();
|
|
const message = isSpecificCloudflaredError(currentState.lastError)
|
|
? currentState.lastError
|
|
: error instanceof Error
|
|
? error.message
|
|
: "Failed to start cloudflared tunnel";
|
|
|
|
await updateStateFile({
|
|
ownerPid: process.pid,
|
|
status: "error",
|
|
lastError: message,
|
|
});
|
|
throw new Error(message);
|
|
} finally {
|
|
startPromise = null;
|
|
}
|
|
}
|
|
|
|
export async function stopCloudflaredTunnel() {
|
|
await stopExistingTunnel();
|
|
const current = await readStateFile();
|
|
await writeStateFile({
|
|
...buildStoppedState(current, !!(await resolveBinary()).binaryPath),
|
|
ownerPid: null,
|
|
});
|
|
tunnelProcess = null;
|
|
tunnelPid = null;
|
|
await clearPidFile();
|
|
return getCloudflaredTunnelStatus();
|
|
}
|