mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
Merged via /merge-batch (lote 2026-08-26, v3.8.51). Boarded no worktree combinado junto com outras ~30 PRs; validação única: typecheck/complexity/cognitive-complexity/changelog-integrity verdes, file-size rebaseado onde necessário (crescimento legítimo), lint com os mesmos 228 achados pré-existentes confirmados via sonda contra o tip puro (não introduzidos por este lote), e ~370 testes focados (unit + vitest) passando. Obrigado pela contribuição.
1075 lines
30 KiB
TypeScript
1075 lines
30 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 CloudflaredNamedTunnelConfig = {
|
|
configPath: string;
|
|
hostname: 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];
|
|
}
|
|
|
|
/**
|
|
* Normalize an operator-supplied public hostname into an `https://host[:port]`
|
|
* origin. Accepts a bare host (`omniroute.example.com`) or a full URL and
|
|
* returns `null` for empty/invalid input.
|
|
*/
|
|
export function normalizeCloudflaredHostname(
|
|
rawHostname: string | null | undefined
|
|
): string | null {
|
|
const value = String(rawHostname || "").trim();
|
|
if (!value) return null;
|
|
|
|
const candidate = /^https?:\/\//i.test(value) ? value : `https://${value}`;
|
|
try {
|
|
const url = new URL(candidate);
|
|
if (!url.hostname) return null;
|
|
return url.origin;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve named/persistent tunnel configuration from the environment. Presence
|
|
* of `CLOUDFLARED_CONFIG` (a path to a locally-managed cloudflared `config.yml`
|
|
* with `tunnel:`, `credentials-file:`, and `ingress:` entries) is what switches
|
|
* the tunnel out of ephemeral quick-tunnel mode. `CLOUDFLARED_HOSTNAME` is an
|
|
* optional override for the public hostname OmniRoute reports as the tunnel's
|
|
* `publicUrl`/`apiUrl`; when unset it is read from the config's first `ingress`
|
|
* hostname (a named tunnel emits no `*.trycloudflare.com` URL to scrape).
|
|
* Returns `null` when no config is set (quick-tunnel mode).
|
|
*/
|
|
export function getCloudflaredNamedTunnelConfig(
|
|
sourceEnv: NodeJS.ProcessEnv = process.env
|
|
): CloudflaredNamedTunnelConfig | null {
|
|
const configPath = String(sourceEnv.CLOUDFLARED_CONFIG || "").trim();
|
|
if (!configPath) return null;
|
|
|
|
return {
|
|
configPath,
|
|
hostname: normalizeCloudflaredHostname(sourceEnv.CLOUDFLARED_HOSTNAME),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract the first `ingress` hostname from a cloudflared `config.yml` body, so
|
|
* OmniRoute can report a named tunnel's public URL without the operator having
|
|
* to repeat the hostname in `CLOUDFLARED_HOSTNAME`. Skips comments and the
|
|
* catch-all rule; returns `null` when no routable hostname is present.
|
|
*/
|
|
export function extractCloudflaredHostnameFromConfig(configText: string): string | null {
|
|
for (const rawLine of String(configText || "").split(/\r?\n/)) {
|
|
const line = rawLine.trim();
|
|
if (!line || line.startsWith("#")) continue;
|
|
const match = line.match(/^-?\s*hostname:\s*["']?([^"'#\s]+)/i);
|
|
if (match && match[1]) {
|
|
return normalizeCloudflaredHostname(match[1]);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Detect the stable cloudflared log line emitted once a named tunnel has an
|
|
* active edge connection. Used as the readiness signal for named tunnels, which
|
|
* (unlike quick tunnels) never print a public URL to stdout/stderr.
|
|
*/
|
|
export function extractCloudflaredConnectionReady(text: string): boolean {
|
|
const value = String(text || "");
|
|
return (
|
|
/Registered tunnel connection/i.test(value) || /Connection [0-9a-f-]+ registered/i.test(value)
|
|
);
|
|
}
|
|
|
|
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,
|
|
namedTunnel: CloudflaredNamedTunnelConfig | null = null
|
|
) {
|
|
if (namedTunnel) {
|
|
// Named/persistent tunnel (locally-managed): run the tunnel defined by the
|
|
// operator's cloudflared config.yml. The config supplies the tunnel UUID,
|
|
// credentials-file, and ingress routing (which host maps to which local
|
|
// service), so no `--url` target is passed here. `run` reads credentials
|
|
// from the config's absolute `credentials-file` path — no `cert.pem` needed.
|
|
return ["tunnel", "--no-autoupdate", "--config", namedTunnel.configPath, "run"];
|
|
}
|
|
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();
|
|
const namedTunnel = getCloudflaredNamedTunnelConfig();
|
|
|
|
if (namedTunnel) {
|
|
// Fail fast with a clear message instead of a 30s readiness timeout when
|
|
// CLOUDFLARED_CONFIG points at a missing/unreadable file.
|
|
try {
|
|
await fs.access(namedTunnel.configPath);
|
|
} catch {
|
|
throw new Error(`cloudflared config file not found: ${namedTunnel.configPath}`);
|
|
}
|
|
// If the operator didn't set CLOUDFLARED_HOSTNAME, read the public hostname
|
|
// from the config's first ingress rule so we can still report publicUrl.
|
|
if (!namedTunnel.hostname) {
|
|
try {
|
|
const configText = await fs.readFile(namedTunnel.configPath, "utf8");
|
|
namedTunnel.hostname = extractCloudflaredHostnameFromConfig(configText);
|
|
} catch {
|
|
// Non-fatal: the tunnel can still run, we just can't report its URL.
|
|
}
|
|
}
|
|
}
|
|
|
|
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, namedTunnel),
|
|
{
|
|
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,
|
|
});
|
|
}
|
|
|
|
if (namedTunnel) {
|
|
// Named tunnels never print a public URL; treat the first registered
|
|
// edge connection as ready and report the operator-configured hostname
|
|
// (if any) as the public URL.
|
|
if (!extractCloudflaredConnectionReady(text)) return;
|
|
|
|
const publicUrl = namedTunnel.hostname;
|
|
await updateStateFile({
|
|
ownerPid: process.pid,
|
|
pid: child.pid,
|
|
publicUrl,
|
|
apiUrl: getTunnelApiUrl(publicUrl),
|
|
status: "running",
|
|
lastError: null,
|
|
});
|
|
|
|
const namedStatus = await getCloudflaredTunnelStatus();
|
|
settle(() => resolve(namedStatus));
|
|
return;
|
|
}
|
|
|
|
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);
|
|
});
|
|
|
|
const readySubject = namedTunnel ? "connection" : "URL";
|
|
|
|
child.once("exit", (code, signal) => {
|
|
void finalizeProcessExit(code, signal);
|
|
settle(() =>
|
|
reject(
|
|
new Error(
|
|
`cloudflared exited before tunnel ${readySubject} was ready (${code ?? "signal"}${signal ? `/${signal}` : ""})`
|
|
)
|
|
)
|
|
);
|
|
});
|
|
|
|
timeout = setTimeout(async () => {
|
|
await stopExistingTunnel();
|
|
settle(() =>
|
|
reject(new Error(`Timed out while waiting for Cloudflare tunnel ${readySubject}`))
|
|
);
|
|
}, 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();
|
|
}
|