feat(FN-5375): enforce manifest-gated cloudflared checksum installation
Added manifest-gated checksum verification for cloudflared remote access tunnels: a pinned manifest validator (Step 1) and enforcement logic (Step 2) wired into the settings memory routes, with aligned tests and documentation covering fail-closed install behavior and pending-manifest guidance. Fusion-Task-Id: FN-5375
This commit is contained in:
committed by
gsxdsm
parent
e814ba9508
commit
dbccdb1275
5
.changeset/fn-5375-cloudflared-manifest.md
Normal file
5
.changeset/fn-5375-cloudflared-manifest.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Harden cloudflared auto-install (dashboard remote-access opt-in flow): add a pinned release manifest and SHA-256 verification, mirroring the FN-5320 Worktrunk pattern. Auto-download fails closed in `upstream-pending-verification` mode; package-manager paths (`brew`, `winget`) are unchanged. Replaces the previous unverified `releases/latest/download` direct-curl install path.
|
||||
@@ -70,7 +70,7 @@ Cloudflare **Quick Tunnel** startup gates (`quickTunnel = true`):
|
||||
|
||||
No Cloudflare account, tunnel token, named tunnel, or pre-created ingress URL is required.
|
||||
|
||||
Dashboard note: in Settings → Remote Access, selecting Cloudflare now performs a proactive `cloudflared` CLI detection check and shows a one-click **Install cloudflared** action (with manual command fallback) if the binary is missing.
|
||||
Dashboard note: in Settings → Remote Access, selecting Cloudflare performs a proactive `cloudflared` CLI detection check and shows a one-click **Install cloudflared** action (with manual command fallback) if the binary is missing. The direct-download path is pinned-manifest gated: until maintainers flip the shipped manifest from `upstream-pending-verification` to a verified tagged release with per-asset `.sha256` sidecars, auto-download fails closed and the UI surfaces package-manager/manual fallback commands.
|
||||
|
||||
Runtime command used by engine:
|
||||
|
||||
|
||||
@@ -588,7 +588,7 @@ The canonical persisted shape is a nested `remoteAccess` object.
|
||||
|
||||
Use **[Remote Access runbook](./remote-access.md)** for setup prerequisites (Tailscale/Cloudflare), tokenized login-link security caveats, and operational troubleshooting. Keep this section as a schema reference.
|
||||
|
||||
When `remoteAccess.activeProvider` is `cloudflare`, the Settings UI fetches `/api/remote/status` and surfaces `cloudflaredAvailable` to show installed/missing state plus a one-click `POST /api/remote/install-cloudflared` action.
|
||||
When `remoteAccess.activeProvider` is `cloudflare`, the Settings UI fetches `/api/remote/status` and surfaces `cloudflaredAvailable` to show installed/missing state plus a one-click `POST /api/remote/install-cloudflared` action. That endpoint preserves package-manager installs (`brew`, `winget`) and gates direct binary download behind a pinned manifest: default `upstream-pending-verification` mode fails closed until maintainers populate verified tagged-release URLs and sha256 sidecars.
|
||||
|
||||
When `remoteAccess.activeProvider` is `tailscale` and the Fusion-managed tunnel is stopped, `/api/remote/status` also returns `externalTunnel` when a pre-existing funnel is detected. The UI exposes two actions: **Use Existing** (start Fusion tunnel lifecycle against the existing funnel) and **Start Fresh** (`POST /api/remote/tunnel/kill-external` then start).
|
||||
|
||||
|
||||
@@ -15,7 +15,13 @@ vi.mock("node:child_process", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { writeFileSync } from "node:fs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { createApiRoutes } from "../routes.js";
|
||||
import {
|
||||
__setCloudflaredManifestForTesting,
|
||||
validateCloudflaredManifest,
|
||||
} from "../routes/register-settings-memory-routes.js";
|
||||
import { request as performRequest } from "../test-request.js";
|
||||
|
||||
function buildRemoteAccessSettings(overrides: Record<string, unknown> = {}) {
|
||||
@@ -106,6 +112,7 @@ function setProcessRuntime(platform: NodeJS.Platform, arch: string): void {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
__setCloudflaredManifestForTesting(null);
|
||||
if (originalPlatformDescriptor) {
|
||||
Object.defineProperty(process, "platform", originalPlatformDescriptor);
|
||||
}
|
||||
@@ -308,6 +315,18 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
expect(result.body).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
const makeVerifiedManifest = (assetName: string, sha256: string) => ({
|
||||
source: "upstream-verified" as const,
|
||||
version: "2026.5.0",
|
||||
verifiedAt: "2026-05-20T00:00:00.000Z",
|
||||
assets: {
|
||||
[assetName]: {
|
||||
url: `https://github.com/cloudflare/cloudflared/releases/download/2026.5.0/${assetName}`,
|
||||
sha256,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
it("installs cloudflared via endpoint and returns install command metadata", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
const { app } = createApp();
|
||||
@@ -316,10 +335,11 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
success: false,
|
||||
command: expect.stringContaining("cloudflared-linux-amd64"),
|
||||
error: expect.stringContaining("upstream-pending-verification"),
|
||||
}));
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-amd64"))).toBe(true);
|
||||
expect(mockExecFile.mock.calls.every(([command]) => command !== "curl")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses arm64 cloudflared binary on Linux arm64", async () => {
|
||||
@@ -330,18 +350,25 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
success: false,
|
||||
command: expect.stringContaining("cloudflared-linux-arm64"),
|
||||
error: expect.stringContaining("upstream-pending-verification"),
|
||||
}));
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-linux-arm64"))).toBe(true);
|
||||
expect(mockExecFile.mock.calls.every(([command]) => command !== "curl")).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to ~/.local/bin when /usr/local/bin move fails with permission error", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
const payload = Buffer.from("cloudflared-ok");
|
||||
const sha256 = createHash("sha256").update(payload).digest("hex");
|
||||
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", sha256));
|
||||
mockExecFile.mockImplementation((command: string, args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
: maybeCallback;
|
||||
if (command === "curl") {
|
||||
writeFileSync("/tmp/cloudflared", payload);
|
||||
}
|
||||
if (command === "mv" && args[1] === "/usr/local/bin/cloudflared") {
|
||||
callback?.(new Error("EPERM"), "", "EPERM");
|
||||
return;
|
||||
@@ -360,6 +387,9 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
|
||||
it("falls back to direct download on macOS when brew is unavailable", async () => {
|
||||
setProcessRuntime("darwin", "arm64");
|
||||
const payload = Buffer.from("cloudflared-darwin");
|
||||
const sha256 = createHash("sha256").update(payload).digest("hex");
|
||||
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-darwin-arm64", sha256));
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
@@ -368,6 +398,9 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
callback?.(new Error("brew not found"), "", "brew not found");
|
||||
return;
|
||||
}
|
||||
if (command === "curl") {
|
||||
writeFileSync("/tmp/cloudflared", payload);
|
||||
}
|
||||
callback?.(null, "", "");
|
||||
});
|
||||
|
||||
@@ -375,16 +408,15 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.body).toEqual(expect.objectContaining({
|
||||
success: true,
|
||||
command: expect.stringContaining("cloudflared-darwin-arm64"),
|
||||
}));
|
||||
expect(result.body).toEqual(expect.objectContaining({ success: true, command: expect.stringContaining("cloudflared-darwin-arm64") }));
|
||||
expect(mockExecFile.mock.calls.some(([command]) => command === "brew")).toBe(false);
|
||||
expect(mockExecFile.mock.calls.some(([command, args]) => command === "curl" && Array.isArray(args) && String(args[3]).includes("cloudflared-darwin-arm64"))).toBe(true);
|
||||
});
|
||||
|
||||
it("returns install failure details when cloudflared installation command fails", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
const sha256 = "a".repeat(64);
|
||||
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", sha256));
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void
|
||||
@@ -406,4 +438,106 @@ describe("remote access provider/lifecycle contracts", () => {
|
||||
error: expect.stringContaining("Command failed"),
|
||||
}));
|
||||
});
|
||||
|
||||
describe("cloudflared manifest verification", () => {
|
||||
it("validateCloudflaredManifest accepts a pending manifest", () => {
|
||||
expect(validateCloudflaredManifest({ source: "upstream-pending-verification", version: null, verifiedAt: null, assets: {} })).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("validateCloudflaredManifest rejects pending manifest with non-empty assets", () => {
|
||||
const result = validateCloudflaredManifest({ source: "upstream-pending-verification", version: null, verifiedAt: null, assets: { a: { url: "x", sha256: "a" } } });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("validateCloudflaredManifest accepts a verified manifest with proper sha256 and tagged URL", () => {
|
||||
expect(validateCloudflaredManifest(makeVerifiedManifest("cloudflared-linux-amd64", "a".repeat(64)))).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("validateCloudflaredManifest rejects verified manifest with releases/latest/download URL", () => {
|
||||
const result = validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/latest/download/a", sha256: "a".repeat(64) } } });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("validateCloudflaredManifest rejects verified manifest with empty sha256", () => {
|
||||
const result = validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/download/v/a", sha256: "" } } });
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("validateCloudflaredManifest rejects verified manifest with uppercase or short sha256", () => {
|
||||
expect(validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/download/v/a", sha256: "A".repeat(64) } } }).ok).toBe(false);
|
||||
expect(validateCloudflaredManifest({ source: "upstream-verified", version: "v", verifiedAt: "2026-01-01T00:00:00.000Z", assets: { a: { url: "https://github.com/cloudflare/cloudflared/releases/download/v/a", sha256: "a".repeat(63) } } }).ok).toBe(false);
|
||||
});
|
||||
|
||||
it("validateCloudflaredManifest never throws on garbage input", () => {
|
||||
for (const input of [null, undefined, 42, "string", []]) {
|
||||
expect(() => validateCloudflaredManifest(input)).not.toThrow();
|
||||
expect(validateCloudflaredManifest(input).ok).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it("installCloudflared fails closed when manifest is upstream-pending-verification", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
expect(result.body.success).toBe(false);
|
||||
expect(result.body.error).toContain("upstream-pending-verification");
|
||||
expect(mockExecFile.mock.calls.every(([command]) => command !== "curl")).toBe(true);
|
||||
});
|
||||
|
||||
it("installCloudflared verifies sha256 and proceeds on verified manifest", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
const payload = Buffer.from("verified-payload");
|
||||
const sha256 = createHash("sha256").update(payload).digest("hex");
|
||||
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", sha256));
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void : maybeCallback;
|
||||
if (command === "curl") writeFileSync("/tmp/cloudflared", payload);
|
||||
callback?.(null, "", "");
|
||||
});
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
expect(result.body.success).toBe(true);
|
||||
expect(mockExecFile.mock.calls.some(([command]) => command === "chmod")).toBe(true);
|
||||
expect(mockExecFile.mock.calls.some(([command]) => command === "mv")).toBe(true);
|
||||
});
|
||||
|
||||
it("installCloudflared aborts when downloaded sha256 does not match", async () => {
|
||||
setProcessRuntime("linux", "x64");
|
||||
__setCloudflaredManifestForTesting(makeVerifiedManifest("cloudflared-linux-amd64", "a".repeat(64)));
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void : maybeCallback;
|
||||
if (command === "curl") writeFileSync("/tmp/cloudflared", Buffer.from("mismatch"));
|
||||
callback?.(null, "", "");
|
||||
});
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
expect(result.body.success).toBe(false);
|
||||
expect(result.body.error).toContain("sha256 mismatch");
|
||||
expect(mockExecFile.mock.calls.some(([command]) => command === "rm")).toBe(true);
|
||||
const firstChmodIndex = mockExecFile.mock.calls.findIndex(([command]) => command === "chmod");
|
||||
const firstMvIndex = mockExecFile.mock.calls.findIndex(([command]) => command === "mv");
|
||||
expect(firstChmodIndex).toBe(-1);
|
||||
expect(firstMvIndex).toBe(-1);
|
||||
});
|
||||
|
||||
it("installCloudflared on win32 (winget) is unchanged by manifest state", async () => {
|
||||
setProcessRuntime("win32", "x64");
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
expect(result.body.success).toBe(true);
|
||||
expect(mockExecFile.mock.calls.some(([command]) => command === "winget")).toBe(true);
|
||||
});
|
||||
|
||||
it("installCloudflared on darwin uses brew when present, regardless of manifest state", async () => {
|
||||
setProcessRuntime("darwin", "arm64");
|
||||
mockExecFile.mockImplementation((command: string, _args: string[], optionsOrCallback: unknown, maybeCallback?: (error: Error | null, stdout?: string, stderr?: string) => void) => {
|
||||
const callback = typeof optionsOrCallback === "function" ? optionsOrCallback as (error: Error | null, stdout?: string, stderr?: string) => void : maybeCallback;
|
||||
callback?.(null, command === "which" ? "/opt/homebrew/bin/brew" : "", "");
|
||||
});
|
||||
const { app } = createApp();
|
||||
const result = await REQUEST(app, "POST", "/api/remote/install-cloudflared", {});
|
||||
expect(result.body.success).toBe(true);
|
||||
expect(mockExecFile.mock.calls.some(([command]) => command === "brew")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
} from "@fusion/engine";
|
||||
import QRCode from "qrcode";
|
||||
import crypto from "node:crypto";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { homedir } from "node:os";
|
||||
import { promisify } from "node:util";
|
||||
@@ -68,6 +69,115 @@ interface SettingsMemoryRouteDeps {
|
||||
discoverDashboardPiExtensions: (cwd: string) => Promise<PiExtensionSettings>;
|
||||
}
|
||||
|
||||
export interface CloudflaredReleaseAsset {
|
||||
url: string;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface CloudflaredReleaseManifest {
|
||||
source: "upstream-pending-verification" | "upstream-verified";
|
||||
version: string | null;
|
||||
verifiedAt: string | null;
|
||||
assets: Record<string, CloudflaredReleaseAsset>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pinned cloudflared release manifest.
|
||||
* Upstream repo: https://github.com/cloudflare/cloudflared
|
||||
* Docs: https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/
|
||||
* Release assets: https://github.com/cloudflare/cloudflared/releases/download/<tag>/<asset>
|
||||
*
|
||||
* Maintainer promotion procedure (FN-5321 external-integration evidence):
|
||||
* 1) Select a specific release tag from the upstream releases page.
|
||||
* 2) For each supported asset, fetch the matching `<asset>.sha256` sidecar from that tagged release.
|
||||
* 3) Set `source` to `upstream-verified`, set `version` to the tag, set `verifiedAt` to ISO8601,
|
||||
* and populate `assets` with tagged URLs + 64-char lowercase sha256 values.
|
||||
*
|
||||
* Until that verification is completed, this remains fail-closed.
|
||||
*/
|
||||
export const CLOUDFLARED_PINNED_RELEASE: CloudflaredReleaseManifest = {
|
||||
source: "upstream-pending-verification",
|
||||
version: null,
|
||||
verifiedAt: null,
|
||||
assets: {},
|
||||
};
|
||||
|
||||
let cloudflaredManifestOverrideForTesting: CloudflaredReleaseManifest | null = null;
|
||||
|
||||
export function __setCloudflaredManifestForTesting(manifest: CloudflaredReleaseManifest | null): void {
|
||||
cloudflaredManifestOverrideForTesting = manifest;
|
||||
}
|
||||
|
||||
function getCloudflaredManifest(): CloudflaredReleaseManifest {
|
||||
return cloudflaredManifestOverrideForTesting ?? CLOUDFLARED_PINNED_RELEASE;
|
||||
}
|
||||
|
||||
export function validateCloudflaredManifest(input: unknown): { ok: true } | { ok: false; missingFields: string[]; reason: string } {
|
||||
if (!input || typeof input !== "object" || Array.isArray(input)) {
|
||||
return { ok: false, missingFields: ["manifest"], reason: "manifest must be an object" };
|
||||
}
|
||||
|
||||
const manifest = input as Partial<CloudflaredReleaseManifest> & { assets?: unknown };
|
||||
const missingFields: string[] = [];
|
||||
|
||||
if (manifest.source !== "upstream-pending-verification" && manifest.source !== "upstream-verified") {
|
||||
missingFields.push("source");
|
||||
}
|
||||
if (!(typeof manifest.version === "string" || manifest.version === null)) {
|
||||
missingFields.push("version");
|
||||
}
|
||||
if (!(typeof manifest.verifiedAt === "string" || manifest.verifiedAt === null)) {
|
||||
missingFields.push("verifiedAt");
|
||||
}
|
||||
|
||||
const assets = manifest.assets;
|
||||
if (!assets || typeof assets !== "object" || Array.isArray(assets)) {
|
||||
missingFields.push("assets");
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return { ok: false, missingFields, reason: `missing or invalid manifest fields: ${missingFields.join(", ")}` };
|
||||
}
|
||||
|
||||
const assetEntries = Object.entries(assets as Record<string, unknown>);
|
||||
|
||||
if (manifest.source === "upstream-pending-verification") {
|
||||
if (assetEntries.length > 0) {
|
||||
missingFields.push("assets");
|
||||
}
|
||||
if (manifest.version !== null) {
|
||||
missingFields.push("version");
|
||||
}
|
||||
if (manifest.verifiedAt !== null) {
|
||||
missingFields.push("verifiedAt");
|
||||
}
|
||||
if (missingFields.length > 0) {
|
||||
return { ok: false, missingFields, reason: "fields must be empty/null when pending" };
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
for (const [assetName, assetRaw] of assetEntries) {
|
||||
if (!assetRaw || typeof assetRaw !== "object" || Array.isArray(assetRaw)) {
|
||||
missingFields.push(`assets.${assetName}`);
|
||||
continue;
|
||||
}
|
||||
const asset = assetRaw as Partial<CloudflaredReleaseAsset>;
|
||||
if (typeof asset.url !== "string" || !asset.url.startsWith("https://github.com/cloudflare/cloudflared/releases/download/")) {
|
||||
missingFields.push(`assets.${assetName}.url`);
|
||||
}
|
||||
if (typeof asset.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(asset.sha256)) {
|
||||
missingFields.push(`assets.${assetName}.sha256`);
|
||||
}
|
||||
}
|
||||
|
||||
if (missingFields.length > 0) {
|
||||
return { ok: false, missingFields, reason: `missing or invalid manifest fields: ${missingFields.join(", ")}` };
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: SettingsMemoryRouteDeps): void {
|
||||
const { router, options, store, runtimeLogger, getProjectContext, rethrowAsApiError } = ctx;
|
||||
const { githubToken, validateModelPresets, sanitizeOverlapIgnorePaths, discoverDashboardPiExtensions } = deps;
|
||||
@@ -158,7 +268,35 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
return [error.message, stderr, stdout].filter(Boolean).join(" | ");
|
||||
}
|
||||
|
||||
async function verifyDownloadedBinaryChecksum(tempPath: string, expectedSha256: string): Promise<void> {
|
||||
const hash = crypto.createHash("sha256");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(tempPath);
|
||||
stream.on("data", (chunk) => hash.update(chunk));
|
||||
stream.on("error", reject);
|
||||
stream.on("end", resolve);
|
||||
});
|
||||
|
||||
const actualSha256 = hash.digest("hex").toLowerCase();
|
||||
const expected = expectedSha256.toLowerCase();
|
||||
if (actualSha256 !== expected) {
|
||||
throw new Error(
|
||||
`cloudflared sha256 mismatch (expected ${expected}, got ${actualSha256}); refusing to install possibly tampered binary`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function installCloudflared(): Promise<{ success: boolean; command: string; error?: string }> {
|
||||
const manifest = getCloudflaredManifest();
|
||||
const manifestValidation = validateCloudflaredManifest(manifest);
|
||||
if (!manifestValidation.ok) {
|
||||
return {
|
||||
success: false,
|
||||
command: resolveCloudflaredInstallCommand(),
|
||||
error: `cloudflared manifest invalid: ${manifestValidation.reason}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const command = resolveCloudflaredInstallCommand();
|
||||
try {
|
||||
@@ -173,12 +311,29 @@ export function registerSettingsMemoryRoutes(ctx: ApiRoutesContext, deps: Settin
|
||||
const downloadBinaryName = process.platform === "darwin" || process.platform === "linux"
|
||||
? resolveCloudflaredBinaryName()
|
||||
: "cloudflared-linux-amd64";
|
||||
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/${downloadBinaryName}`;
|
||||
const tempPath = "/tmp/cloudflared";
|
||||
|
||||
const installFromDirectDownload = async (): Promise<void> => {
|
||||
attemptedCommands.push(`curl -L --output ${tempPath} ${downloadUrl}`);
|
||||
await execFileAsync("curl", ["-L", "--output", tempPath, downloadUrl], { timeout: 120_000 });
|
||||
const asset = manifest.assets[downloadBinaryName];
|
||||
if (manifest.source === "upstream-pending-verification" || !asset) {
|
||||
const packageManagerCommand = resolveCloudflaredInstallCommand();
|
||||
attemptedCommands.push(packageManagerCommand);
|
||||
const error = new Error(
|
||||
`cloudflared install blocked: upstream-pending-verification manifest is not pinned for ${downloadBinaryName}; run '${packageManagerCommand}' (current platform), 'brew install cloudflared' (macOS), or 'winget install Cloudflare.cloudflared' (Windows), or pin a verified release at https://github.com/cloudflare/cloudflared/releases`,
|
||||
);
|
||||
throw Object.assign(error, { stage: "manifest-unverified" });
|
||||
}
|
||||
|
||||
attemptedCommands.push(`curl -L --output ${tempPath} ${asset.url}`);
|
||||
await execFileAsync("curl", ["-L", "--output", tempPath, asset.url], { timeout: 120_000 });
|
||||
attemptedCommands.push(`verify sha256 of ${tempPath} against pinned manifest`);
|
||||
try {
|
||||
await verifyDownloadedBinaryChecksum(tempPath, asset.sha256);
|
||||
} catch (error) {
|
||||
attemptedCommands.push(`rm -f ${tempPath} (checksum mismatch cleanup)`);
|
||||
await execFileAsync("rm", ["-f", tempPath], { timeout: 10_000 }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
|
||||
attemptedCommands.push(`chmod +x ${tempPath}`);
|
||||
await execFileAsync("chmod", ["+x", tempPath], { timeout: 30_000 });
|
||||
|
||||
Reference in New Issue
Block a user