2026-02-21 04:42:46 -05:00
|
|
|
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 "./state.js";
|
|
|
|
|
|
|
|
|
|
const BIN_DIR = path.join(os.homedir(), ".9router", "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);
|
|
|
|
|
|
2026-04-08 23:16:11 -04:00
|
|
|
const CLOUDFLARED_VERSION = "2026.3.0";
|
|
|
|
|
const GITHUB_BASE_URL = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}`;
|
2026-02-21 04:42:46 -05:00
|
|
|
|
|
|
|
|
const PLATFORM_MAPPINGS = {
|
|
|
|
|
darwin: {
|
|
|
|
|
x64: "cloudflared-darwin-amd64.tgz",
|
2026-04-08 23:16:11 -04:00
|
|
|
arm64: "cloudflared-darwin-arm64.tgz"
|
2026-02-21 04:42:46 -05:00
|
|
|
},
|
|
|
|
|
win32: {
|
2026-04-08 23:16:11 -04:00
|
|
|
x64: "cloudflared-windows-amd64.exe",
|
|
|
|
|
x32: "cloudflared-windows-386.exe"
|
2026-02-21 04:42:46 -05:00
|
|
|
},
|
|
|
|
|
linux: {
|
|
|
|
|
x64: "cloudflared-linux-amd64",
|
|
|
|
|
arm64: "cloudflared-linux-arm64"
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
if (!binaryName) {
|
|
|
|
|
throw new Error(`Unsupported architecture: ${arch} for platform ${platform}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return `${GITHUB_BASE_URL}/${binaryName}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function downloadFile(url, dest) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const file = fs.createWriteStream(dest);
|
|
|
|
|
|
|
|
|
|
https.get(url, (response) => {
|
|
|
|
|
if ([301, 302].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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
response.pipe(file);
|
|
|
|
|
|
|
|
|
|
file.on("finish", () => {
|
|
|
|
|
file.close(() => resolve(dest));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
file.on("error", (err) => {
|
|
|
|
|
file.close();
|
|
|
|
|
fs.unlinkSync(dest);
|
|
|
|
|
reject(err);
|
|
|
|
|
});
|
|
|
|
|
}).on("error", (err) => {
|
|
|
|
|
file.close();
|
|
|
|
|
if (fs.existsSync(dest)) fs.unlinkSync(dest);
|
|
|
|
|
reject(err);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function ensureCloudflared() {
|
|
|
|
|
if (!fs.existsSync(BIN_DIR)) {
|
|
|
|
|
fs.mkdirSync(BIN_DIR, { recursive: true });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (fs.existsSync(BIN_PATH)) {
|
|
|
|
|
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") : BIN_PATH;
|
|
|
|
|
|
|
|
|
|
await downloadFile(url, downloadDest);
|
|
|
|
|
|
|
|
|
|
if (isArchive) {
|
2026-04-14 00:48:59 -04:00
|
|
|
execSync(`tar -xzf "${downloadDest}" -C "${BIN_DIR}"`, { stdio: "pipe", windowsHide: true });
|
2026-02-21 04:42:46 -05:00
|
|
|
fs.unlinkSync(downloadDest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!IS_WINDOWS) {
|
|
|
|
|
fs.chmodSync(BIN_PATH, "755");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return BIN_PATH;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let cloudflaredProcess = null;
|
2026-02-22 09:44:11 -05:00
|
|
|
let unexpectedExitHandler = null;
|
|
|
|
|
|
|
|
|
|
/** Register a callback to be called when cloudflared exits unexpectedly after connecting */
|
|
|
|
|
export function setUnexpectedExitHandler(handler) {
|
|
|
|
|
unexpectedExitHandler = handler;
|
|
|
|
|
}
|
2026-02-21 04:42:46 -05:00
|
|
|
|
|
|
|
|
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,
|
2026-03-15 22:11:19 -04:00
|
|
|
windowsHide: true,
|
2026-02-21 04:42:46 -05:00
|
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cloudflaredProcess = child;
|
|
|
|
|
savePid(child.pid);
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
let connectionCount = 0;
|
2026-02-22 09:44:11 -05:00
|
|
|
let resolved = false;
|
2026-02-21 04:42:46 -05:00
|
|
|
const timeout = setTimeout(() => {
|
2026-02-22 09:44:11 -05:00
|
|
|
resolved = true;
|
2026-02-21 04:42:46 -05:00
|
|
|
resolve(child);
|
|
|
|
|
}, 90000);
|
|
|
|
|
|
|
|
|
|
const handleLog = (data) => {
|
|
|
|
|
const msg = data.toString();
|
2026-03-13 22:37:29 -04:00
|
|
|
// 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;
|
2026-02-22 09:44:11 -05:00
|
|
|
if (connectionCount >= 4 && !resolved) {
|
|
|
|
|
resolved = true;
|
2026-02-21 04:42:46 -05:00
|
|
|
clearTimeout(timeout);
|
|
|
|
|
resolve(child);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
child.stdout.on("data", handleLog);
|
|
|
|
|
child.stderr.on("data", handleLog);
|
|
|
|
|
|
|
|
|
|
child.on("error", (err) => {
|
2026-02-22 09:44:11 -05:00
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
reject(err);
|
|
|
|
|
}
|
2026-02-21 04:42:46 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
child.on("exit", (code) => {
|
2026-02-22 09:44:11 -05:00
|
|
|
cloudflaredProcess = null;
|
|
|
|
|
clearPid();
|
2026-03-13 22:37:29 -04:00
|
|
|
const wasConnected = resolved; // true = already connected successfully
|
2026-02-22 09:44:11 -05:00
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
if (connectionCount === 0) {
|
|
|
|
|
reject(new Error(`cloudflared exited with code ${code}`));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-13 22:37:29 -04:00
|
|
|
// Only notify on unexpected exit AFTER successful connection
|
|
|
|
|
if (wasConnected && unexpectedExitHandler) {
|
2026-02-22 09:44:11 -05:00
|
|
|
unexpectedExitHandler();
|
2026-02-21 04:42:46 -05:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 12:47:13 -04:00
|
|
|
/**
|
|
|
|
|
* 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 child = spawn(binaryPath, ["tunnel", "--url", `http://localhost:${localPort}`, "--config", configPath, "--no-autoupdate"], {
|
|
|
|
|
detached: false,
|
|
|
|
|
windowsHide: true,
|
|
|
|
|
stdio: ["ignore", "pipe", "pipe"]
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
cloudflaredProcess = child;
|
|
|
|
|
savePid(child.pid);
|
|
|
|
|
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
let resolved = false;
|
2026-03-19 13:26:01 -04:00
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-19 12:47:13 -04:00
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
if (resolved) return;
|
|
|
|
|
resolved = true;
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new Error("Quick tunnel timed out"));
|
|
|
|
|
}, 90000);
|
|
|
|
|
|
2026-03-20 01:44:20 -04:00
|
|
|
let lastUrl = null;
|
|
|
|
|
|
2026-03-19 12:47:13 -04:00
|
|
|
const handleLog = (data) => {
|
|
|
|
|
const msg = data.toString();
|
2026-03-19 13:26:01 -04:00
|
|
|
const tunnelUrl = getQuickTunnelUrlFromLog(msg);
|
2026-03-20 01:44:20 -04:00
|
|
|
if (!tunnelUrl) return;
|
|
|
|
|
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
// First URL — resolve the promise, do NOT call onUrlUpdate (caller handles initial register)
|
2026-03-19 12:47:13 -04:00
|
|
|
resolved = true;
|
2026-03-20 01:44:20 -04:00
|
|
|
lastUrl = tunnelUrl;
|
2026-03-19 12:47:13 -04:00
|
|
|
clearTimeout(timeout);
|
|
|
|
|
cleanup();
|
|
|
|
|
resolve({ child, tunnelUrl });
|
2026-03-20 01:44:20 -04:00
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// URL changed after initial connect — notify caller to re-register
|
|
|
|
|
if (tunnelUrl !== lastUrl) {
|
|
|
|
|
lastUrl = tunnelUrl;
|
2026-03-19 12:47:13 -04:00
|
|
|
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) => {
|
|
|
|
|
cloudflaredProcess = null;
|
|
|
|
|
clearPid();
|
|
|
|
|
if (!resolved) {
|
|
|
|
|
resolved = true;
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
cleanup();
|
|
|
|
|
reject(new Error(`cloudflared exited with code ${code}`));
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (unexpectedExitHandler) unexpectedExitHandler();
|
|
|
|
|
cleanup();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 04:42:46 -05:00
|
|
|
export function killCloudflared() {
|
|
|
|
|
if (cloudflaredProcess) {
|
|
|
|
|
try {
|
|
|
|
|
cloudflaredProcess.kill();
|
|
|
|
|
} catch (e) { /* ignore */ }
|
|
|
|
|
cloudflaredProcess = null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const pid = loadPid();
|
|
|
|
|
if (pid) {
|
|
|
|
|
try {
|
|
|
|
|
process.kill(pid);
|
|
|
|
|
} catch (e) { /* ignore */ }
|
|
|
|
|
clearPid();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Kill any remaining cloudflared processes
|
|
|
|
|
try {
|
2026-04-14 00:48:59 -04:00
|
|
|
execSync("pkill -f cloudflared 2>/dev/null || true", { stdio: "ignore", windowsHide: true });
|
2026-02-21 04:42:46 -05:00
|
|
|
} catch (e) { /* ignore */ }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function isCloudflaredRunning() {
|
|
|
|
|
const pid = loadPid();
|
|
|
|
|
if (!pid) return false;
|
|
|
|
|
try {
|
|
|
|
|
process.kill(pid, 0);
|
|
|
|
|
return true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|