diff --git a/.changeset/fresh-tunnel-binaries.md b/.changeset/fresh-tunnel-binaries.md new file mode 100644 index 0000000..f75585c --- /dev/null +++ b/.changeset/fresh-tunnel-binaries.md @@ -0,0 +1,5 @@ +--- +"cloudflared": minor +--- + +Extend `install` with options for a minimum binary version and a configurable refresh interval. Refresh CLI-managed binaries every 30 days by default, preserve explicit version pins, and keep working binaries when an optional refresh fails. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7e3f6a9..c25d52a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -47,7 +47,7 @@ jobs: run: pnpm build - name: Test (w/ service) - run: sudo -E ${{ steps.pnpm.outputs.bin_dest }}/pnpm test + run: sudo --preserve-env=CI,VERBOSE,CLOUDFLARED_VERSION,TUNNEL_TOKEN,PNPM_HOME ${{ steps.pnpm.outputs.bin_dest }}/pnpm test if: matrix.os != 'windows-latest' - name: Test (w/o service) diff --git a/README.md b/README.md index 7e4de13..cddf468 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@ A Node.js package that allows you to easily create HTTPS tunnels using Cloudflare's `cloudflared` command-line tool. It provides a typed API for creating tunnels and managing the `cloudflared` binary installation. -> This tool will automatically install the [latest version of `cloudflared`](https://github.com/cloudflare/cloudflared/releases/latest) (or `CLOUDFLARED_VERSION` env var if exists) at the first time. -> Then, it just passes down the command to `cloudflared`. +> This tool automatically installs the [latest version of `cloudflared`](https://github.com/cloudflare/cloudflared/releases/latest) on first use and updates to the latest version at most once every 30 days. +> Set `CLOUDFLARED_VERSION` to install an exact version and disable automatic updates. ## Installation @@ -29,6 +29,9 @@ yarn global add cloudflared > If `CLOUDFLARED_VERSION` env var is set, it will install the specified version of `cloudflared`, otherwise it will install the latest version. +Set `CLOUDFLARED_UPDATE_INTERVAL` to the interval in milliseconds to change the CLI's +update frequency, or `0` to disable periodic updates. + ## CLI Usage You can use the `cloudflared` command-line tool to create HTTPS tunnels. You can find the usage of `cloudflared` [here](https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/tunnel-useful-commands/). @@ -73,6 +76,34 @@ spawn(bin, ["--version"], { stdio: "inherit" }); - `bin`: The path of the binary. - `install`: A function that installs the binary to the given path. +### Automatic updates + +Library consumers can pass options to `install` before spawning the binary or +creating a `Tunnel`. Synchronous `Tunnel` constructors do not check for updates. + +```js +import { bin, install } from "cloudflared"; +import { spawn } from "node:child_process"; + +await install(bin, { + minimumVersion: "2026.10.2", +}); +spawn(bin, ["--version"], { stdio: "inherit" }); +``` + +- `minimumVersion`: Upgrade an older binary immediately, even if a periodic check + is not due. Missing or older binaries install the latest release, unless a + version is pinned. +- `checkInterval`: Milliseconds between optional update attempts. Omitting it or + passing `undefined` uses the default of 30 days. Set it to `0` to disable periodic + updates while still installing missing binaries and enforcing the minimum. + +`CLOUDFLARED_VERSION` pins take precedence over periodic updates. A pin below +`minimumVersion` is rejected. + +For compatibility, an exact version can be installed with `install(path, version)`. +Passing a version string or omitting the second argument always downloads the binary. + ### Tunnel Checkout [`examples/tunnel.mjs`](examples/tunnel.mjs) (or [`examples/tunnel.js`](examples/tunnel.js) for CommonJS). diff --git a/src/_tests/download.test.ts b/src/_tests/download.test.ts new file mode 100644 index 0000000..ea90d8b --- /dev/null +++ b/src/_tests/download.test.ts @@ -0,0 +1,60 @@ +import { EventEmitter } from "node:events"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { PassThrough } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { install_linux } from "../install.js"; + +const mocks = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("node:https", () => ({ default: { get: mocks.get } })); + +let directory: string; +let binary: string; +let response: PassThrough & { statusCode: number }; +let request: EventEmitter & { + destroy: ReturnType; + setTimeout: ReturnType; + end: ReturnType; +}; + +beforeEach(() => { + directory = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-download-test-")); + binary = path.join(directory, "cloudflared"); + response = Object.assign(new PassThrough(), { statusCode: 200 }); + request = Object.assign(new EventEmitter(), { + destroy: vi.fn((error?: Error) => { + if (error) request.emit("error", error); + }), + setTimeout: vi.fn(), + end: vi.fn(), + }); + mocks.get.mockImplementation((_url, callback) => { + queueMicrotask(() => callback(response)); + return request; + }); +}); + +afterEach(() => { + fs.rmSync(directory, { recursive: true, force: true }); +}); + +describe("download", () => { + it("rejects an interrupted response and removes the partial file", async () => { + const installed = install_linux(binary, "2026.10.2"); + queueMicrotask(() => { + response.write("partial binary"); + response.destroy(new Error("connection reset")); + }); + await expect(installed).rejects.toThrow("connection reset"); + expect(fs.existsSync(binary)).toBe(false); + }); + + it("bounds stalled download requests", async () => { + mocks.get.mockReturnValue(request); + const installed = install_linux(binary, "2026.10.2"); + expect(request.setTimeout).toHaveBeenCalledWith(30_000, expect.any(Function)); + request.setTimeout.mock.calls[0][1](); + await expect(installed).rejects.toThrow("download timed out"); + }); +}); diff --git a/src/_tests/index.test.ts b/src/_tests/index.test.ts index 9949f7a..089a037 100644 --- a/src/_tests/index.test.ts +++ b/src/_tests/index.test.ts @@ -1,4 +1,4 @@ -import { ChildProcess } from "node:child_process"; +import { ChildProcess, execFileSync } from "node:child_process"; import fs from "node:fs"; import { Tunnel, bin, install, service } from "../lib.js"; import { describe, it, expect, beforeAll } from "vitest"; @@ -14,6 +14,9 @@ describe("install", { timeout: 60_000 }, () => { await install(bin); expect(fs.existsSync(bin)).toBe(true); + expect(execFileSync(bin, ["--version"], { encoding: "utf8" })).toMatch( + /^cloudflared version \d+\.\d+\.\d+/, + ); }); }); diff --git a/src/_tests/install-update.test.ts b/src/_tests/install-update.test.ts new file mode 100644 index 0000000..00c5779 --- /dev/null +++ b/src/_tests/install-update.test.ts @@ -0,0 +1,189 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { EventEmitter } from "node:events"; +import { Readable } from "node:stream"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ exec: vi.fn(), download: vi.fn() })); +vi.mock("node:child_process", async (original) => { + const actual = await original(); + const { promisify } = await import("node:util"); + return { + ...actual, + execFile: Object.assign(vi.fn(), { [promisify.custom]: mocks.exec }), + execSync: vi.fn((_command: string, { cwd }: { cwd: string }) => { + const archive = fs.readdirSync(cwd).find((file) => file.endsWith(".tgz"))!; + fs.writeFileSync( + path.join(cwd, "cloudflared"), + fs.readFileSync(path.join(cwd, archive)), + ); + }), + }; +}); +vi.mock("node:https", () => ({ + default: { + get: (url: string, callback: (response: Readable) => void) => { + const request = Object.assign(new EventEmitter(), { + setTimeout: vi.fn(), + end: vi.fn(), + destroy: vi.fn(), + }); + queueMicrotask(() => { + try { + callback( + Object.assign(Readable.from([mocks.download(url)]), { + statusCode: 200, + headers: {}, + }), + ); + } catch (error) { + request.emit("error", error); + } + }); + return request; + }, + }, +})); + +let directory: string; +let binary: string; +let downloaded: string; + +beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + vi.stubEnv("CLOUDFLARED_VERSION", "latest"); + directory = fs.mkdtempSync(path.join(os.tmpdir(), "cloudflared-update-test-")); + binary = path.join(directory, "cloudflared"); + downloaded = "2026.10.2"; + mocks.exec.mockImplementation(async (file: string) => ({ + stdout: `cloudflared version ${fs.readFileSync(file, "utf8")} (built today)\n`, + })); + mocks.download.mockImplementation(() => downloaded); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + fs.rmSync(directory, { recursive: true, force: true }); +}); + +async function ensure(options: import("../install.js").InstallOptions = {}) { + const { install } = await import("../install.js"); + return install(binary, options); +} + +function writeBinary(version: string, daysOld = 0) { + fs.writeFileSync(binary, version); + const time = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000); + fs.utimesSync(binary, time, time); +} + +describe("conditional install", () => { + it("installs a missing binary through a separate staging directory", async () => { + await expect(ensure({ minimumVersion: "2026.10.1" })).resolves.toBe(binary); + expect(mocks.download).toHaveBeenCalledWith(expect.stringContaining("/latest/download/")); + expect(path.dirname(mocks.exec.mock.calls[1][0])).not.toBe(directory); + expect(fs.readFileSync(binary, "utf8")).toBe(downloaded); + expect(fs.readdirSync(directory).sort()).toEqual(["cloudflared", "cloudflared.last-check"]); + }); + + it.each([undefined, 7 * 24 * 60 * 60 * 1000])( + "refreshes only when the default or custom interval has elapsed: %s", + async (checkInterval) => { + const days = checkInterval === undefined ? 30 : 7; + writeBinary("2026.9.0", days - 1); + await ensure({ checkInterval }); + expect(mocks.download).not.toHaveBeenCalled(); + + writeBinary("2026.9.0", days + 1); + await ensure({ checkInterval }); + await ensure({ checkInterval }); + expect(mocks.download).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(binary, "utf8")).toBe(downloaded); + }, + ); + + it("disables periodic refreshes while still enforcing a numeric minimum", async () => { + writeBinary("2026.10.2", 60); + await ensure({ minimumVersion: "2026.9.0", checkInterval: 0 }); + expect(mocks.download).not.toHaveBeenCalled(); + + downloaded = "2026.11.0"; + await ensure({ minimumVersion: "2026.10.3", checkInterval: 0 }); + expect(mocks.download).toHaveBeenCalledWith(expect.stringContaining("/latest/download/")); + expect(fs.readFileSync(binary, "utf8")).toBe(downloaded); + }); + + it("respects an exact version pin and rejects a conflicting minimum", async () => { + vi.stubEnv("CLOUDFLARED_VERSION", downloaded); + writeBinary(downloaded, 60); + await ensure(); + expect(mocks.download).not.toHaveBeenCalled(); + + writeBinary("2026.9.0"); + await ensure(); + expect(mocks.download).toHaveBeenCalledWith( + expect.stringContaining(`/download/${downloaded}/`), + ); + expect(fs.readFileSync(binary, "utf8")).toBe(downloaded); + await expect(ensure({ minimumVersion: "2026.11.0" })).rejects.toThrow( + "CLOUDFLARED_VERSION is older than minimumVersion", + ); + }); + + it.each(["download", "replacement"])( + "keeps the working binary after a failed %s and waits before retrying", + async (failure) => { + writeBinary("2026.9.0", 31); + if (failure === "download") { + mocks.download.mockImplementation(() => { + throw new Error("offline"); + }); + } else { + vi.spyOn(fs, "renameSync").mockImplementation(() => { + throw new Error("EBUSY"); + }); + } + await ensure(); + await ensure(); + expect(mocks.download).toHaveBeenCalledTimes(1); + expect(fs.readFileSync(binary, "utf8")).toBe("2026.9.0"); + expect(fs.readdirSync(directory).sort()).toEqual([ + "cloudflared", + "cloudflared.last-check", + ]); + }, + ); + + it("propagates required installation failures without damaging the installed binary", async () => { + mocks.download.mockImplementation(() => { + throw new Error("offline"); + }); + writeBinary("2026.9.0"); + await expect(ensure({ minimumVersion: downloaded })).rejects.toThrow("offline"); + expect(fs.readFileSync(binary, "utf8")).toBe("2026.9.0"); + + fs.unlinkSync(binary); + await expect(ensure()).rejects.toThrow("offline"); + expect(fs.existsSync(binary)).toBe(false); + }); +}); + +describe("latest or specified version installation", () => { + it("always downloads with omitted or explicit versions", async () => { + writeBinary(downloaded); + const { install } = await import("../install.js"); + await install(binary); + expect(mocks.download).toHaveBeenLastCalledWith( + expect.stringContaining("/latest/download/"), + ); + await install(binary, downloaded); + expect(mocks.download).toHaveBeenLastCalledWith( + expect.stringContaining(`/download/${downloaded}/`), + ); + expect(mocks.download).toHaveBeenCalledTimes(2); + expect(mocks.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/src/index.ts b/src/index.ts index c8c19c3..64f6870 100644 --- a/src/index.ts +++ b/src/index.ts @@ -74,9 +74,13 @@ export async function main(): Promise { } } - if (!fs.existsSync(bin)) { - console.log("Installed cloudflared to " + (await install(bin))); - } + await install(bin, { + checkInterval: process.env.CLOUDFLARED_BIN + ? 0 + : process.env.CLOUDFLARED_UPDATE_INTERVAL === undefined + ? undefined + : Number(process.env.CLOUDFLARED_UPDATE_INTERVAL), + }); const sub = spawn(bin, args, { stdio: "inherit" }); diff --git a/src/install.ts b/src/install.ts index 8ebd27c..6cf7d0c 100644 --- a/src/install.ts +++ b/src/install.ts @@ -1,10 +1,22 @@ +import { execFile, execSync } from "node:child_process"; import fs from "node:fs"; -import path from "node:path"; import https from "node:https"; -import { execSync } from "node:child_process"; +import path from "node:path"; +import { pipeline } from "node:stream"; +import { promisify } from "node:util"; import { CLOUDFLARED_VERSION, RELEASE_BASE } from "./constants"; import { UnsupportedError } from "./error"; +const exec = promisify(execFile); +const MONTH = 30 * 24 * 60 * 60 * 1000; + +export interface InstallOptions { + /** Always upgrade binaries older than this version, regardless of the check interval. */ + minimumVersion?: string; + /** Milliseconds between refresh attempts. Defaults to 30 days; zero disables refreshes. */ + checkInterval?: number; +} + const LINUX_URL: Partial> = { arm64: "cloudflared-linux-arm64", arm: "cloudflared-linux-arm", @@ -31,11 +43,80 @@ function resolve_base(version: string): string { /** * Install cloudflared to the given path. + * Pass a version string (or omit it) to always download. + * Pass options to install only when missing, below the minimum, or due for a refresh. + * Optional refresh failures keep the installed binary; required installations throw. * @param to The path to the binary to install. - * @param version The version of cloudflared to install. - * @returns The path to the binary that was installed. + * @param version A version to download, or options for conditional installation. + * @returns The path to the installed binary. */ -export async function install(to: string, version = CLOUDFLARED_VERSION): Promise { +export async function install( + to: string, + version: string | InstallOptions = CLOUDFLARED_VERSION, +): Promise { + if (typeof version === "string") { + return installExact(to, version); + } + + const { minimumVersion, checkInterval = MONTH } = version; + if (!Number.isFinite(checkInterval) || checkInterval < 0) { + throw new Error("checkInterval must be a finite, non-negative number"); + } + const pinned = CLOUDFLARED_VERSION !== "latest" ? CLOUDFLARED_VERSION : undefined; + if (minimumVersion !== undefined) validateVersion(minimumVersion); + if (pinned) { + validateVersion(pinned); + if (minimumVersion && compareVersions(pinned, minimumVersion) < 0) { + throw new Error("CLOUDFLARED_VERSION is older than minimumVersion"); + } + } + + const current = await binaryVersion(to).catch(() => undefined); + const required = + !current || + (pinned !== undefined && current !== pinned) || + (minimumVersion !== undefined && compareVersions(current, minimumVersion) < 0); + const checked = `${to}.last-check`; + + if (!required) { + if (pinned || checkInterval === 0) return to; + const lastCheck = fs.existsSync(checked) ? checked : to; + if (Date.now() - fs.statSync(lastCheck).mtimeMs < checkInterval) return to; + } + + let temporary: string | undefined; + try { + fs.mkdirSync(path.dirname(to), { recursive: true }); + // Record attempts too, so offline users do not retry a refresh on every invocation. + fs.writeFileSync(checked, ""); + temporary = fs.mkdtempSync(path.join(path.dirname(to), ".cloudflared-")); + const candidate = path.join( + temporary, + process.platform === "win32" ? "cloudflared.exe" : "cloudflared", + ); + await installExact(candidate, pinned || "latest"); + const downloaded = await binaryVersion(candidate); + if ( + (pinned && downloaded !== pinned) || + (minimumVersion && compareVersions(downloaded, minimumVersion) < 0) + ) { + throw new Error( + `Downloaded cloudflared ${downloaded} does not meet the requested version`, + ); + } + // Never downgrade a working binary during an optional refresh. + if (!required && current && compareVersions(downloaded, current) <= 0) return to; + fs.renameSync(candidate, to); + } catch (error) { + if (required) throw error; + if (process.env.VERBOSE) console.warn("Could not refresh cloudflared:", error); + } finally { + if (temporary) fs.rmSync(temporary, { recursive: true, force: true }); + } + return to; +} + +async function installExact(to: string, version: string): Promise { if (process.platform === "linux") { return install_linux(to, version); } else if (process.platform === "darwin") { @@ -61,7 +142,7 @@ export async function install_linux(to: string, version = CLOUDFLARED_VERSION): export async function install_macos(to: string, version = CLOUDFLARED_VERSION): Promise { let arch = process.arch; - if (version !== "latest" && version_number(version) < 20240802) { + if (version !== "latest" && compareVersions(version, "2024.8.2") < 0) { // arm64 is only supported starting from 2024.8.2 arch = "x64"; } @@ -111,15 +192,13 @@ function download(url: string, to: string, redirect = 0): Promise { if (res.statusCode && res.statusCode >= 200 && res.statusCode < 300) { const file = fs.createWriteStream(to); - file.on("finish", () => { - file.close(() => resolve(to)); - }); - - file.on("error", (err) => { - fs.unlink(to, () => reject(err)); + pipeline(res, file, (err) => { + if (err) { + fs.unlink(to, () => reject(err)); + } else { + resolve(to); + } }); - - res.pipe(file); } else { request.destroy(); reject(new Error(`HTTP response with status code: ${res.statusCode}`)); @@ -129,12 +208,31 @@ function download(url: string, to: string, redirect = 0): Promise { request.on("error", (err) => { reject(err); }); + request.setTimeout(30_000, () => + request.destroy(new Error("cloudflared download timed out")), + ); request.end(); }); } -function version_number(semver: string): number { - const [major, minor, patch] = semver.split(".").map(Number); - return major * 10000 + minor * 100 + patch; +async function binaryVersion(file: string): Promise { + const { stdout } = await exec(file, ["--version"], { timeout: 5_000, windowsHide: true }); + const match = /^cloudflared version (\d+\.\d+\.\d+)(?:\s|$)/.exec(stdout); + if (!match) throw new Error(`Cannot read cloudflared version from ${file}`); + return match[1]; +} + +function validateVersion(version: string): void { + if (!/^\d+\.\d+\.\d+$/.test(version)) { + throw new Error(`Invalid cloudflared version: ${version}`); + } +} + +function compareVersions(left: string, right: string): number { + validateVersion(left); + validateVersion(right); + const a = left.split(".").map(Number); + const b = right.split(".").map(Number); + return a[0] - b[0] || a[1] - b[1] || a[2] - b[2]; }