Refactor Tunnel
This commit is contained in:
1 parent
96a9a2b36d
commit
b876e0225a
35 files changed
+642
-574
No files matched your search
@@ -0,0 +1,438 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import https from "https";
|
||||
import os from "os";
|
||||
import { execSync, spawn } from "child_process";
|
||||
import { savePid, loadPid, clearPid } from "./pid.js";
|
||||
import { DATA_DIR } from "@/lib/dataDir.js";
|
||||
|
||||
const BIN_DIR = path.join(DATA_DIR, "bin");
|
||||
const BINARY_NAME = "cloudflared";
|
||||
const IS_WINDOWS = os.platform() === "win32";
|
||||
const BIN_NAME = IS_WINDOWS ? `${BINARY_NAME}.exe` : BINARY_NAME;
|
||||
const BIN_PATH = path.join(BIN_DIR, BIN_NAME);
|
||||
const POWERSHELL_HIDDEN_COMMAND = "powershell -NoProfile -NonInteractive -WindowStyle Hidden -Command";
|
||||
const DEFAULT_QUICK_TUNNEL_PROTOCOL = "http2";
|
||||
const QUICK_TUNNEL_PROTOCOLS = new Set(["http2", "quic", "auto"]);
|
||||
|
||||
const GITHUB_BASE_URL = "https://github.com/cloudflare/cloudflared/releases/latest/download";
|
||||
|
||||
const PLATFORM_MAPPINGS = {
|
||||
darwin: {
|
||||
x64: "cloudflared-darwin-amd64.tgz",
|
||||
arm64: "cloudflared-darwin-arm64.tgz"
|
||||
},
|
||||
win32: {
|
||||
x64: "cloudflared-windows-amd64.exe",
|
||||
ia32: "cloudflared-windows-386.exe",
|
||||
arm64: "cloudflared-windows-386.exe"
|
||||
},
|
||||
linux: {
|
||||
x64: "cloudflared-linux-amd64",
|
||||
arm64: "cloudflared-linux-arm64"
|
||||
}
|
||||
};
|
||||
|
||||
// Fallback order: prefer smallest/most-compatible binary per platform
|
||||
const PLATFORM_FALLBACK = {
|
||||
darwin: "cloudflared-darwin-amd64.tgz",
|
||||
win32: "cloudflared-windows-386.exe",
|
||||
linux: "cloudflared-linux-amd64"
|
||||
};
|
||||
|
||||
function getDownloadUrl() {
|
||||
const platform = os.platform();
|
||||
const arch = os.arch();
|
||||
|
||||
const platformMapping = PLATFORM_MAPPINGS[platform];
|
||||
if (!platformMapping) {
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
|
||||
const binaryName = platformMapping[arch] || PLATFORM_FALLBACK[platform];
|
||||
return `${GITHUB_BASE_URL}/${binaryName}`;
|
||||
}
|
||||
|
||||
// Download state — shared so status API can read it
|
||||
const dlState = { downloading: false, progress: 0 };
|
||||
|
||||
export function getDownloadStatus() {
|
||||
return { downloading: dlState.downloading, progress: dlState.progress };
|
||||
}
|
||||
|
||||
function downloadFile(url, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(dest);
|
||||
|
||||
https.get(url, (response) => {
|
||||
if ([301, 302, 303, 307, 308].includes(response.statusCode)) {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
reject(new Error(`Download failed with status ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const totalBytes = parseInt(response.headers["content-length"], 10) || 0;
|
||||
let receivedBytes = 0;
|
||||
dlState.downloading = true;
|
||||
dlState.progress = 0;
|
||||
|
||||
response.on("data", (chunk) => {
|
||||
receivedBytes += chunk.length;
|
||||
if (totalBytes > 0) dlState.progress = Math.round((receivedBytes / totalBytes) * 100);
|
||||
});
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on("finish", () => {
|
||||
dlState.downloading = false;
|
||||
dlState.progress = 100;
|
||||
file.close(() => resolve(dest));
|
||||
});
|
||||
|
||||
file.on("error", (err) => {
|
||||
dlState.downloading = false;
|
||||
dlState.progress = 0;
|
||||
file.close();
|
||||
fs.unlinkSync(dest);
|
||||
reject(err);
|
||||
});
|
||||
}).on("error", (err) => {
|
||||
dlState.downloading = false;
|
||||
dlState.progress = 0;
|
||||
file.close();
|
||||
if (fs.existsSync(dest)) fs.unlinkSync(dest);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const MIN_BINARY_SIZE = 1024 * 1024; // 1MB - cloudflared is ~30MB+
|
||||
|
||||
// Validate binary is executable on current platform and not truncated
|
||||
function isValidBinary(filePath) {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.size < MIN_BINARY_SIZE) return false;
|
||||
const fd = fs.openSync(filePath, "r");
|
||||
const buf = Buffer.alloc(4);
|
||||
fs.readSync(fd, buf, 0, 4, 0);
|
||||
fs.closeSync(fd);
|
||||
const magic = buf.toString("hex");
|
||||
if (IS_WINDOWS) return magic.startsWith("4d5a"); // PE (MZ)
|
||||
if (os.platform() === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe");
|
||||
return magic.startsWith("7f454c46"); // ELF (Linux)
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let downloadPromise = null;
|
||||
|
||||
export async function ensureCloudflared() {
|
||||
if (downloadPromise) return downloadPromise;
|
||||
downloadPromise = _ensureCloudflared().finally(() => { downloadPromise = null; });
|
||||
return downloadPromise;
|
||||
}
|
||||
|
||||
async function _ensureCloudflared() {
|
||||
if (!fs.existsSync(BIN_DIR)) {
|
||||
fs.mkdirSync(BIN_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
// Clean up incomplete downloads from previous runs
|
||||
const tmpPath = `${BIN_PATH}.tmp`;
|
||||
if (fs.existsSync(tmpPath)) {
|
||||
try { fs.unlinkSync(tmpPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
if (fs.existsSync(BIN_PATH)) {
|
||||
if (!isValidBinary(BIN_PATH)) {
|
||||
console.log("[cloudflared] Invalid binary detected, re-downloading...");
|
||||
fs.unlinkSync(BIN_PATH);
|
||||
} else {
|
||||
if (!IS_WINDOWS) fs.chmodSync(BIN_PATH, "755");
|
||||
return BIN_PATH;
|
||||
}
|
||||
}
|
||||
|
||||
const url = getDownloadUrl();
|
||||
const isArchive = url.endsWith(".tgz");
|
||||
const downloadDest = isArchive ? path.join(BIN_DIR, "cloudflared.tgz.tmp") : tmpPath;
|
||||
|
||||
await downloadFile(url, downloadDest);
|
||||
|
||||
if (isArchive) {
|
||||
execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true });
|
||||
fs.unlinkSync(downloadDest);
|
||||
} else {
|
||||
fs.renameSync(downloadDest, BIN_PATH);
|
||||
}
|
||||
|
||||
if (!IS_WINDOWS) {
|
||||
fs.chmodSync(BIN_PATH, "755");
|
||||
}
|
||||
|
||||
return BIN_PATH;
|
||||
}
|
||||
|
||||
let cloudflaredProcess = null;
|
||||
let unexpectedExitHandler = null;
|
||||
|
||||
/** Register a callback to be called when cloudflared exits unexpectedly after connecting */
|
||||
export function setUnexpectedExitHandler(handler) {
|
||||
unexpectedExitHandler = handler;
|
||||
}
|
||||
|
||||
export async function spawnCloudflared(tunnelToken) {
|
||||
const binaryPath = await ensureCloudflared();
|
||||
|
||||
const child = spawn(binaryPath, ["tunnel", "run", "--dns-resolver-addrs", "1.1.1.1:53", "--token", tunnelToken], {
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
cwd: os.tmpdir(),
|
||||
stdio: ["ignore", "pipe", "pipe"]
|
||||
});
|
||||
|
||||
cloudflaredProcess = child;
|
||||
savePid(child.pid);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let connectionCount = 0;
|
||||
let resolved = false;
|
||||
const timeout = setTimeout(() => {
|
||||
resolved = true;
|
||||
resolve(child);
|
||||
}, 90000);
|
||||
|
||||
const handleLog = (data) => {
|
||||
const msg = data.toString();
|
||||
// Count exact occurrences in this chunk (each chunk may contain multiple lines)
|
||||
const matches = msg.match(/Registered tunnel connection/g);
|
||||
if (matches) {
|
||||
connectionCount += matches.length;
|
||||
if (connectionCount >= 4 && !resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
resolve(child);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", handleLog);
|
||||
child.stderr.on("data", handleLog);
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
cloudflaredProcess = null;
|
||||
clearPid();
|
||||
const wasConnected = resolved; // true = already connected successfully
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
// Collect stderr output for better error diagnosis
|
||||
let stderrOutput = "";
|
||||
if (child.stderr && !child.stderr.destroyed) {
|
||||
// Try to read any buffered stderr (may not have all output but helps with common errors)
|
||||
stderrOutput = " Check cloudflared logs for details.";
|
||||
}
|
||||
if (code === 1) {
|
||||
// Common exit code 1 issues: invalid token, auth failure, network issues
|
||||
reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Ensure your tunnel token is valid and network is reachable.`));
|
||||
} else if (code === 2) {
|
||||
reject(new Error(`cloudflared exited with code ${code}${stderrOutput} Check if required arguments are correct.`));
|
||||
} else {
|
||||
reject(new Error(`cloudflared exited with code ${code}${stderrOutput}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Watchdog (initializeApp) handles recovery — no auto-reconnect here
|
||||
if (wasConnected && unexpectedExitHandler) unexpectedExitHandler();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn cloudflared quick tunnel (no account needed)
|
||||
* Returns the generated trycloudflare.com URL
|
||||
*/
|
||||
export async function spawnQuickTunnel(localPort, onUrlUpdate) {
|
||||
const binaryPath = await ensureCloudflared();
|
||||
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-quick-"));
|
||||
const configPath = path.join(configDir, "config.yml");
|
||||
// Avoid using default ~/.cloudflared/config.yml, which can conflict with quick tunnel behavior.
|
||||
fs.writeFileSync(configPath, "# quick-tunnel config placeholder\n", "utf8");
|
||||
|
||||
let isCleaned = false;
|
||||
const cleanup = () => {
|
||||
if (isCleaned) return;
|
||||
isCleaned = true;
|
||||
try {
|
||||
fs.rmSync(configDir, { recursive: true, force: true });
|
||||
} catch (e) { /* ignore */ }
|
||||
};
|
||||
|
||||
const requestedProtocol = String(process.env.TUNNEL_TRANSPORT_PROTOCOL || process.env.CLOUDFLARED_PROTOCOL || DEFAULT_QUICK_TUNNEL_PROTOCOL).trim().toLowerCase();
|
||||
const tunnelProtocol = QUICK_TUNNEL_PROTOCOLS.has(requestedProtocol) ? requestedProtocol : DEFAULT_QUICK_TUNNEL_PROTOCOL;
|
||||
const child = spawn(binaryPath, ["tunnel", "--url", `http://127.0.0.1:${localPort}`, "--config", configPath, "--no-autoupdate", "--retries", "99"], {
|
||||
detached: false,
|
||||
windowsHide: true,
|
||||
cwd: os.tmpdir(),
|
||||
env: {
|
||||
...process.env,
|
||||
TUNNEL_TRANSPORT_PROTOCOL: tunnelProtocol,
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
cloudflaredProcess = child;
|
||||
savePid(child.pid);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let resolved = false;
|
||||
// Keep a small tail of raw cloudflared logs to surface real failure causes
|
||||
let logTail = "";
|
||||
|
||||
function getQuickTunnelUrlFromLog(message) {
|
||||
// cloudflared logs may contain "api.trycloudflare.com" as well,
|
||||
// but that is NOT the quick-tunnel endpoint we need.
|
||||
const regex = /https:\/\/([a-z0-9-]+)\.trycloudflare\.com/gi;
|
||||
const candidates = [];
|
||||
|
||||
for (const match of message.matchAll(regex)) {
|
||||
const host = match[1];
|
||||
if (host === "api") continue;
|
||||
candidates.push(`https://${host}.trycloudflare.com`);
|
||||
}
|
||||
|
||||
if (!candidates.length) return null;
|
||||
return candidates[candidates.length - 1];
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
cleanup();
|
||||
reject(new Error(`Quick tunnel timed out. Last log: ${logTail.slice(-800) || "(empty)"}`));
|
||||
}, 90000);
|
||||
|
||||
let lastUrl = null;
|
||||
|
||||
const handleLog = (data) => {
|
||||
const msg = data.toString();
|
||||
logTail = (logTail + msg).slice(-4000);
|
||||
const tunnelUrl = getQuickTunnelUrlFromLog(msg);
|
||||
if (!tunnelUrl) return;
|
||||
|
||||
if (!resolved) {
|
||||
// First URL — resolve the promise, do NOT call onUrlUpdate (caller handles initial register)
|
||||
resolved = true;
|
||||
lastUrl = tunnelUrl;
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
console.log(`[Tunnel] cloudflared URL: ${tunnelUrl}`);
|
||||
resolve({ child, tunnelUrl });
|
||||
return;
|
||||
}
|
||||
|
||||
// URL changed after initial connect — notify caller to re-register
|
||||
if (tunnelUrl !== lastUrl) {
|
||||
console.log(`[Tunnel] cloudflared URL changed: ${tunnelUrl}`);
|
||||
lastUrl = tunnelUrl;
|
||||
if (onUrlUpdate) onUrlUpdate(tunnelUrl);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout.on("data", handleLog);
|
||||
child.stderr.on("data", handleLog);
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
cloudflaredProcess = null;
|
||||
clearPid();
|
||||
console.log(`[Tunnel] cloudflared exit code=${code} signal=${signal}`);
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
const tail = logTail.slice(-600).trim() || "(empty)";
|
||||
if (code === 1) {
|
||||
reject(new Error(`cloudflared quick tunnel exited (code 1). Common causes: (1) outbound port 7844 (TCP/UDP) blocked, (2) TryCloudflare service issue, (3) cannot reach 127.0.0.1:${localPort}, (4) protocol (http2/quic) blocked by network. Last log: ${tail}`));
|
||||
} else if (code === 2) {
|
||||
reject(new Error(`cloudflared exited (code 2). Bad arguments. Last log: ${tail}`));
|
||||
} else {
|
||||
reject(new Error(`cloudflared exited (code ${code}). Last log: ${tail}`));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (unexpectedExitHandler) unexpectedExitHandler();
|
||||
cleanup();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Kill cloudflared processes whose command line targets the given port (any host).
|
||||
// Boundary check ensures :20128 doesn't match :201280 or :202128.
|
||||
function killCloudflaredByPort(port) {
|
||||
if (!port) return;
|
||||
try {
|
||||
if (IS_WINDOWS) {
|
||||
const psCmd = `Get-CimInstance Win32_Process -Filter \\"Name='cloudflared.exe'\\" | Where-Object { $_.CommandLine -match ':${port}(\\D|$)' } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force }`;
|
||||
execSync(`${POWERSHELL_HIDDEN_COMMAND} "${psCmd}"`, { stdio: "ignore", windowsHide: true });
|
||||
} else {
|
||||
execSync(`pkill -f "cloudflared.*:${port}([^0-9]|$)" 2>/dev/null || true`, { stdio: "ignore", windowsHide: true });
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
export function killCloudflared(localPort) {
|
||||
if (cloudflaredProcess) {
|
||||
try {
|
||||
cloudflaredProcess.kill();
|
||||
} catch (e) { /* ignore */ }
|
||||
cloudflaredProcess = null;
|
||||
}
|
||||
|
||||
const pid = loadPid();
|
||||
if (pid) {
|
||||
try {
|
||||
process.kill(pid);
|
||||
} catch (e) { /* ignore */ }
|
||||
clearPid();
|
||||
}
|
||||
|
||||
killCloudflaredByPort(localPort);
|
||||
}
|
||||
|
||||
export function isCloudflaredRunning() {
|
||||
const pid = loadPid();
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user