619 lines
17 KiB
JavaScript
619 lines
17 KiB
JavaScript
import { spawn, spawnSync } from 'child_process';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
import yaml from 'yaml';
|
|
import {
|
|
createExecutableSearchEnv,
|
|
resolveExecutableLaunchTarget,
|
|
} from './tunnels/executable-search.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const TRY_CF_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/i;
|
|
|
|
const DEFAULT_STARTUP_TIMEOUT_MS = 30000;
|
|
const MANAGED_TUNNEL_STARTUP_TIMEOUT_MS = 20000;
|
|
const MANAGED_TUNNEL_LIVENESS_FALLBACK_MS = 6000;
|
|
const TUNNEL_MODE_QUICK = 'quick';
|
|
const TUNNEL_MODE_MANAGED_REMOTE = 'managed-remote';
|
|
const TUNNEL_MODE_MANAGED_LOCAL = 'managed-local';
|
|
|
|
export async function checkCloudflaredAvailable() {
|
|
const target = resolveExecutableLaunchTarget('cloudflared');
|
|
if (target) {
|
|
try {
|
|
const result = spawnSync(target.command, ['--version'], {
|
|
encoding: 'utf8',
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
env: target.env,
|
|
});
|
|
if (result.status === 0) {
|
|
return { available: true, path: target.command, version: result.stdout.trim() };
|
|
}
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
return { available: false, path: null, version: null };
|
|
}
|
|
|
|
function printCloudflareTunnelInstallHelp() {
|
|
const platform = process.platform;
|
|
let installCmd = '';
|
|
|
|
if (platform === 'darwin') {
|
|
installCmd = 'brew install cloudflared';
|
|
} else if (platform === 'win32') {
|
|
installCmd = 'winget install --id Cloudflare.cloudflared';
|
|
} else {
|
|
installCmd = 'Download from https://github.com/cloudflare/cloudflared/releases';
|
|
}
|
|
|
|
console.log(`
|
|
╔══════════════════════════════════════════════════════════════════╗
|
|
║ Cloudflare tunnel requires 'cloudflared' to be installed ║
|
|
╚══════════════════════════════════════════════════════════════════╝
|
|
|
|
Install instructions for your platform:
|
|
|
|
macOS: brew install cloudflared
|
|
Windows: winget install --id Cloudflare.cloudflared
|
|
Linux: Download from https://github.com/cloudflare/cloudflared/releases
|
|
|
|
Or visit: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/downloads/
|
|
`);
|
|
}
|
|
|
|
const spawnCloudflared = (args, envOverrides = {}, resolvedBinaryPath = 'cloudflared') => spawn(resolvedBinaryPath, args, {
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
env: {
|
|
...createExecutableSearchEnv(),
|
|
CF_TELEMETRY_DISABLE: '1',
|
|
...envOverrides,
|
|
},
|
|
killSignal: 'SIGINT',
|
|
});
|
|
|
|
const normalizeHostname = (value) => {
|
|
if (typeof value !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = value.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
try {
|
|
const parsed = trimmed.includes('://') ? new URL(trimmed) : new URL(`https://${trimmed}`);
|
|
const hostname = parsed.hostname.trim().toLowerCase();
|
|
if (!hostname || hostname.includes('*')) {
|
|
return null;
|
|
}
|
|
return hostname;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
export function normalizeCloudflareTunnelHostname(value) {
|
|
return normalizeHostname(value);
|
|
}
|
|
|
|
export async function checkCloudflareApiReachability({ fetchImpl = globalThis.fetch, timeoutMs = 5000 } = {}) {
|
|
if (typeof fetchImpl !== 'function') {
|
|
return {
|
|
reachable: false,
|
|
status: null,
|
|
error: 'Fetch API is unavailable in this runtime.',
|
|
};
|
|
}
|
|
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
try {
|
|
const response = await fetchImpl('https://api.trycloudflare.com/', {
|
|
method: 'GET',
|
|
signal: controller.signal,
|
|
});
|
|
return {
|
|
reachable: true,
|
|
status: response.status,
|
|
error: null,
|
|
};
|
|
} catch (error) {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
return {
|
|
reachable: false,
|
|
status: null,
|
|
error: message,
|
|
};
|
|
} finally {
|
|
clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
const READY_LOG_PATTERNS = [
|
|
/registered tunnel connection/i,
|
|
/connection[^\n]*registered/i,
|
|
/starting metrics server/i,
|
|
/connected to edge/i,
|
|
];
|
|
|
|
const MANAGED_LOCAL_CONFIG_MAX_BYTES = 256 * 1024;
|
|
const MANAGED_LOCAL_CONFIG_ALLOWED_EXTENSIONS = new Set(['.yml', '.yaml', '.json']);
|
|
|
|
const FATAL_LOG_PATTERNS = [
|
|
/error parsing.*config/i,
|
|
/failed to .*config/i,
|
|
/invalid token/i,
|
|
/unauthorized/i,
|
|
/credentials file .* not found/i,
|
|
/provided tunnel credentials are invalid/i,
|
|
];
|
|
|
|
function isCloudflaredReadyLogLine(line) {
|
|
if (!line) {
|
|
return false;
|
|
}
|
|
return READY_LOG_PATTERNS.some((pattern) => pattern.test(line));
|
|
}
|
|
|
|
function isCloudflaredFatalLogLine(line) {
|
|
if (!line) {
|
|
return false;
|
|
}
|
|
return FATAL_LOG_PATTERNS.some((pattern) => pattern.test(line));
|
|
}
|
|
|
|
function assertReadableFile(filePath, contextLabel) {
|
|
let stats;
|
|
try {
|
|
stats = fs.statSync(filePath);
|
|
} catch {
|
|
throw new Error(`${contextLabel} file was not found. Select a valid cloudflared config file.`);
|
|
}
|
|
|
|
if (!stats.isFile()) {
|
|
throw new Error(`${contextLabel} path is not a file. Select a cloudflared config file.`);
|
|
}
|
|
|
|
const extension = path.extname(filePath).toLowerCase();
|
|
if (!MANAGED_LOCAL_CONFIG_ALLOWED_EXTENSIONS.has(extension)) {
|
|
throw new Error(`${contextLabel} must be a .yml, .yaml, or .json file.`);
|
|
}
|
|
|
|
if (stats.size <= 0) {
|
|
throw new Error(`${contextLabel} file is empty.`);
|
|
}
|
|
if (stats.size > MANAGED_LOCAL_CONFIG_MAX_BYTES) {
|
|
throw new Error(`${contextLabel} file is too large (max ${MANAGED_LOCAL_CONFIG_MAX_BYTES} bytes).`);
|
|
}
|
|
|
|
try {
|
|
fs.accessSync(filePath, fs.constants.R_OK);
|
|
} catch {
|
|
throw new Error(`${contextLabel} file is not readable. Check file permissions and try again.`);
|
|
}
|
|
}
|
|
|
|
function extractHostnameFromCloudflaredConfigDetailed(configPath) {
|
|
if (typeof configPath !== 'string' || configPath.trim().length === 0) {
|
|
return { hostname: null, parseError: null };
|
|
}
|
|
|
|
let raw;
|
|
try {
|
|
raw = fs.readFileSync(configPath, 'utf8');
|
|
} catch {
|
|
return {
|
|
hostname: null,
|
|
parseError: new Error('Could not read the managed local tunnel config file. Check that the file exists and is accessible.'),
|
|
};
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = yaml.parse(raw);
|
|
} catch {
|
|
return {
|
|
hostname: null,
|
|
parseError: new Error('Managed local tunnel config is invalid. Use a valid cloudflared YAML/JSON config file.'),
|
|
};
|
|
}
|
|
|
|
const ingress = Array.isArray(parsed?.ingress) ? parsed.ingress : [];
|
|
for (const rule of ingress) {
|
|
const hostname = normalizeHostname(rule?.hostname);
|
|
if (hostname) {
|
|
return { hostname, parseError: null };
|
|
}
|
|
}
|
|
|
|
return { hostname: null, parseError: null };
|
|
}
|
|
|
|
const extractHostnameFromCloudflaredConfig = (configPath) => {
|
|
return extractHostnameFromCloudflaredConfigDetailed(configPath).hostname;
|
|
};
|
|
|
|
const getDefaultCloudflaredConfigPath = () => path.join(os.homedir(), '.cloudflared', 'config.yml');
|
|
|
|
export function inspectManagedLocalCloudflareConfig({ configPath, hostname } = {}) {
|
|
const requestedPath = typeof configPath === 'string' ? configPath.trim() : '';
|
|
const effectiveConfigPath = requestedPath || getDefaultCloudflaredConfigPath();
|
|
|
|
try {
|
|
if (requestedPath) {
|
|
assertReadableFile(effectiveConfigPath, 'Managed local tunnel config');
|
|
} else {
|
|
assertReadableFile(effectiveConfigPath, 'Managed local tunnel default config');
|
|
}
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
effectiveConfigPath,
|
|
resolvedHostname: null,
|
|
error: error instanceof Error ? error.message : String(error),
|
|
};
|
|
}
|
|
|
|
const configHostnameResult = extractHostnameFromCloudflaredConfigDetailed(effectiveConfigPath);
|
|
if (configHostnameResult.parseError) {
|
|
return {
|
|
ok: false,
|
|
effectiveConfigPath,
|
|
resolvedHostname: null,
|
|
error: configHostnameResult.parseError.message,
|
|
};
|
|
}
|
|
|
|
const resolvedHostname = normalizeHostname(hostname) || configHostnameResult.hostname;
|
|
if (!resolvedHostname) {
|
|
return {
|
|
ok: false,
|
|
effectiveConfigPath,
|
|
resolvedHostname: null,
|
|
error: 'Managed local tunnel hostname is required (set --hostname or include ingress hostname in config).',
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
effectiveConfigPath,
|
|
resolvedHostname,
|
|
error: null,
|
|
};
|
|
}
|
|
|
|
async function waitForManagedTunnelReady(child, { modeLabel }) {
|
|
await new Promise((resolve, reject) => {
|
|
let settled = false;
|
|
let sawOutput = false;
|
|
|
|
const finish = (handler, value) => {
|
|
if (settled) {
|
|
return;
|
|
}
|
|
settled = true;
|
|
clearTimeout(fallbackTimer);
|
|
clearTimeout(hardTimeout);
|
|
child.stdout?.off('data', onStdout);
|
|
child.stderr?.off('data', onStderr);
|
|
child.off('exit', onExit);
|
|
handler(value);
|
|
};
|
|
|
|
const inspectChunk = (chunk) => {
|
|
const text = chunk.toString('utf8');
|
|
if (text.trim().length > 0) {
|
|
sawOutput = true;
|
|
}
|
|
const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
|
for (const line of lines) {
|
|
if (isCloudflaredReadyLogLine(line)) {
|
|
finish(resolve, null);
|
|
return;
|
|
}
|
|
if (isCloudflaredFatalLogLine(line)) {
|
|
finish(reject, new Error(`Cloudflared failed to start ${modeLabel}: ${line}`));
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
const onStdout = (chunk) => {
|
|
inspectChunk(chunk);
|
|
};
|
|
|
|
const onStderr = (chunk) => {
|
|
inspectChunk(chunk);
|
|
};
|
|
|
|
const onExit = (code) => {
|
|
finish(reject, new Error(`Cloudflared exited while starting ${modeLabel} (code ${code ?? 'unknown'})`));
|
|
};
|
|
|
|
child.stdout?.on('data', onStdout);
|
|
child.stderr?.on('data', onStderr);
|
|
child.once('exit', onExit);
|
|
|
|
const fallbackTimer = setTimeout(() => {
|
|
if (sawOutput) {
|
|
finish(resolve, null);
|
|
}
|
|
}, MANAGED_TUNNEL_LIVENESS_FALLBACK_MS);
|
|
|
|
const hardTimeout = setTimeout(() => {
|
|
finish(reject, new Error(`Timed out waiting for cloudflared to initialize ${modeLabel}. Check your tunnel config and credentials.`));
|
|
}, MANAGED_TUNNEL_STARTUP_TIMEOUT_MS);
|
|
});
|
|
}
|
|
|
|
export async function startCloudflareQuickTunnel({ originUrl }) {
|
|
const cfCheck = await checkCloudflaredAvailable();
|
|
|
|
if (!cfCheck.available) {
|
|
printCloudflareTunnelInstallHelp();
|
|
throw new Error('cloudflared is not installed');
|
|
}
|
|
|
|
console.log(`Using cloudflared: ${cfCheck.path} (${cfCheck.version})`);
|
|
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-'));
|
|
|
|
const child = spawnCloudflared(['tunnel', '--url', originUrl], { HOME: tempDir }, cfCheck.path);
|
|
|
|
let publicUrl = null;
|
|
let tunnelReady = false;
|
|
|
|
const onData = (chunk, isStderr) => {
|
|
const text = chunk.toString('utf8');
|
|
|
|
if (!tunnelReady) {
|
|
const match = text.match(TRY_CF_URL_REGEX);
|
|
if (match) {
|
|
publicUrl = match[0];
|
|
tunnelReady = true;
|
|
}
|
|
}
|
|
|
|
process.stderr.write(isStderr ? text : '');
|
|
};
|
|
|
|
child.stdout.on('data', (chunk) => onData(chunk, false));
|
|
child.stderr.on('data', (chunk) => onData(chunk, true));
|
|
|
|
child.on('error', (error) => {
|
|
console.error(`Cloudflared error: ${error.message}`);
|
|
cleanupTempDir();
|
|
});
|
|
|
|
const cleanupTempDir = () => {
|
|
try {
|
|
if (fs.existsSync(tempDir)) {
|
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
}
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
};
|
|
|
|
await new Promise((resolve, reject) => {
|
|
const timeout = setTimeout(() => {
|
|
if (!publicUrl) {
|
|
try { child.kill('SIGINT'); } catch { /* ignore */ }
|
|
cleanupTempDir();
|
|
reject(new Error('Tunnel URL not received within 30 seconds'));
|
|
}
|
|
}, DEFAULT_STARTUP_TIMEOUT_MS);
|
|
|
|
const checkReady = setInterval(() => {
|
|
if (publicUrl) {
|
|
clearTimeout(timeout);
|
|
clearInterval(checkReady);
|
|
resolve(null);
|
|
}
|
|
}, 100);
|
|
|
|
child.on('exit', (code) => {
|
|
clearTimeout(timeout);
|
|
clearInterval(checkReady);
|
|
cleanupTempDir();
|
|
if (code !== null && code !== 0) {
|
|
reject(new Error(`Cloudflared exited with code ${code}`));
|
|
}
|
|
});
|
|
});
|
|
|
|
return {
|
|
mode: TUNNEL_MODE_QUICK,
|
|
stop: () => {
|
|
try {
|
|
child.kill('SIGINT');
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
},
|
|
process: child,
|
|
getPublicUrl: () => publicUrl,
|
|
};
|
|
}
|
|
|
|
export async function startCloudflareManagedRemoteTunnel({ token, hostname, tokenFilePath }) {
|
|
const cfCheck = await checkCloudflaredAvailable();
|
|
|
|
if (!cfCheck.available) {
|
|
printCloudflareTunnelInstallHelp();
|
|
throw new Error('cloudflared is not installed');
|
|
}
|
|
|
|
const normalizedToken = typeof token === 'string' ? token.trim() : '';
|
|
const normalizedHost = typeof hostname === 'string' ? hostname.trim().toLowerCase() : '';
|
|
|
|
if (!normalizedToken) {
|
|
throw new Error('Managed remote tunnel token is required');
|
|
}
|
|
if (!normalizedHost) {
|
|
throw new Error('Managed remote tunnel hostname is required');
|
|
}
|
|
|
|
let effectiveTokenFilePath = typeof tokenFilePath === 'string' ? tokenFilePath : null;
|
|
let tempTokenFile = null;
|
|
|
|
if (!effectiveTokenFilePath) {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-cf-token-'));
|
|
effectiveTokenFilePath = path.join(tempDir, 'token');
|
|
fs.writeFileSync(effectiveTokenFilePath, normalizedToken, { encoding: 'utf8', mode: 0o600 });
|
|
tempTokenFile = { dir: tempDir, path: effectiveTokenFilePath };
|
|
}
|
|
|
|
const child = spawnCloudflared(['tunnel', 'run', '--token-file', effectiveTokenFilePath], {}, cfCheck.path);
|
|
const publicUrl = `https://${normalizedHost}`;
|
|
|
|
child.stdout.on('data', () => {
|
|
// Keep stream drained, but avoid logging potentially sensitive output.
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
const text = chunk.toString('utf8');
|
|
process.stderr.write(text);
|
|
});
|
|
|
|
const cleanupTempTokenFile = () => {
|
|
if (tempTokenFile) {
|
|
try {
|
|
if (fs.existsSync(tempTokenFile.dir)) {
|
|
fs.rmSync(tempTokenFile.dir, { recursive: true, force: true });
|
|
}
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
}
|
|
};
|
|
|
|
child.on('error', (error) => {
|
|
console.error(`Cloudflared error: ${error.message}`);
|
|
cleanupTempTokenFile();
|
|
});
|
|
|
|
child.on('exit', () => {
|
|
cleanupTempTokenFile();
|
|
});
|
|
|
|
try {
|
|
await waitForManagedTunnelReady(child, { modeLabel: 'managed-remote tunnel' });
|
|
} catch (error) {
|
|
try { child.kill('SIGINT'); } catch { /* ignore */ }
|
|
cleanupTempTokenFile();
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
mode: TUNNEL_MODE_MANAGED_REMOTE,
|
|
stop: () => {
|
|
try {
|
|
child.kill('SIGINT');
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
cleanupTempTokenFile();
|
|
},
|
|
process: child,
|
|
getPublicUrl: () => publicUrl,
|
|
};
|
|
}
|
|
|
|
export async function startCloudflareManagedLocalTunnel({ configPath, hostname }) {
|
|
const cfCheck = await checkCloudflaredAvailable();
|
|
|
|
if (!cfCheck.available) {
|
|
printCloudflareTunnelInstallHelp();
|
|
throw new Error('cloudflared is not installed');
|
|
}
|
|
|
|
const requestedPath = typeof configPath === 'string' ? configPath.trim() : '';
|
|
const effectiveConfigPath = requestedPath || getDefaultCloudflaredConfigPath();
|
|
|
|
if (requestedPath) {
|
|
assertReadableFile(effectiveConfigPath, 'Managed local tunnel config');
|
|
} else {
|
|
assertReadableFile(effectiveConfigPath, 'Managed local tunnel default config');
|
|
}
|
|
|
|
const configHostnameResult = extractHostnameFromCloudflaredConfigDetailed(effectiveConfigPath);
|
|
if (configHostnameResult.parseError) {
|
|
throw configHostnameResult.parseError;
|
|
}
|
|
|
|
const resolvedHost = normalizeHostname(hostname) || configHostnameResult.hostname;
|
|
|
|
if (!resolvedHost) {
|
|
throw new Error('Managed local tunnel hostname is required (use --tunnel-hostname or add an ingress hostname to the cloudflared config)');
|
|
}
|
|
|
|
const args = ['tunnel'];
|
|
if (requestedPath) {
|
|
args.push('--config', effectiveConfigPath);
|
|
}
|
|
args.push('run');
|
|
|
|
const child = spawnCloudflared(args, {}, cfCheck.path);
|
|
const publicUrl = `https://${resolvedHost}`;
|
|
|
|
child.stdout.on('data', () => {
|
|
// Keep stream drained, but avoid logging potentially sensitive output.
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
const text = chunk.toString('utf8');
|
|
process.stderr.write(text);
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
console.error(`Cloudflared error: ${error.message}`);
|
|
});
|
|
|
|
try {
|
|
await waitForManagedTunnelReady(child, { modeLabel: 'managed-local tunnel' });
|
|
} catch (error) {
|
|
try { child.kill('SIGINT'); } catch { /* ignore */ }
|
|
throw error;
|
|
}
|
|
|
|
return {
|
|
mode: TUNNEL_MODE_MANAGED_LOCAL,
|
|
stop: () => {
|
|
try {
|
|
child.kill('SIGINT');
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
},
|
|
process: child,
|
|
getPublicUrl: () => publicUrl,
|
|
getResolvedHostname: () => resolvedHost,
|
|
getEffectiveConfigPath: () => effectiveConfigPath,
|
|
};
|
|
}
|
|
|
|
async function startCloudflareTunnel({ originUrl, port }) {
|
|
void port;
|
|
return startCloudflareQuickTunnel({ originUrl });
|
|
}
|
|
|
|
export function printTunnelWarning() {
|
|
console.log(`
|
|
⚠️ Quick Tunnel Limitations:
|
|
|
|
• Provider limits may apply
|
|
• URLs are temporary and will expire when the tunnel stops
|
|
• Password protection is required for tunnel access
|
|
|
|
For production use, set up a persistent provider tunnel or static domain.
|
|
`);
|
|
}
|