mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-01 04:42:10 +03:00
Integrated into release/v3.8.0 — configurable chat log truncation, CHAT_DEBUG_FILE mode, cloudflared state file lock
934 lines
25 KiB
TypeScript
934 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) {
|
|
await ensureTunnelDir();
|
|
await fs.writeFile(getStateFilePath(), 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), {
|
|
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();
|
|
}
|